Spaces:
Running
Running
Upload 13 files
Browse files- routers/analyze.py +509 -0
- routers/curiosity.py +403 -0
- routers/filter.py +548 -0
- routers/getnews.py +134 -0
- routers/image.py +573 -0
- routers/inference.py +566 -0
- routers/instagram.py +368 -0
- routers/memoriam.py +203 -0
- routers/news.py +291 -0
- routers/search.py +382 -0
- routers/subtitle.py +497 -0
- routers/twitter.py +502 -0
- routers/video.py +396 -0
routers/analyze.py
ADDED
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import sys
|
3 |
+
import importlib.util
|
4 |
+
from pathlib import Path
|
5 |
+
import re
|
6 |
+
import json
|
7 |
+
import time
|
8 |
+
import logging
|
9 |
+
import gc
|
10 |
+
import asyncio
|
11 |
+
import aiohttp
|
12 |
+
from typing import Optional, Dict, Any
|
13 |
+
from fastapi import FastAPI, APIRouter, HTTPException
|
14 |
+
from pydantic import BaseModel
|
15 |
+
from urllib.parse import quote
|
16 |
+
|
17 |
+
# IMPORTANTE: Configurar variáveis de ambiente e PyTorch ANTES de qualquer importação que use PyTorch
|
18 |
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
19 |
+
os.environ["OMP_NUM_THREADS"] = "2"
|
20 |
+
os.environ["MKL_NUM_THREADS"] = "2"
|
21 |
+
|
22 |
+
# Configurar PyTorch ANTES de importar qualquer módulo que o use
|
23 |
+
import torch
|
24 |
+
torch.set_num_threads(2)
|
25 |
+
|
26 |
+
# Verificar se já foi configurado antes de tentar definir interop threads
|
27 |
+
if not hasattr(torch, '_interop_threads_set'):
|
28 |
+
try:
|
29 |
+
torch.set_num_interop_threads(1)
|
30 |
+
torch._interop_threads_set = True
|
31 |
+
except RuntimeError as e:
|
32 |
+
if "cannot set number of interop threads" in str(e):
|
33 |
+
print(f"Warning: Could not set interop threads: {e}")
|
34 |
+
else:
|
35 |
+
raise e
|
36 |
+
|
37 |
+
# Supabase Config
|
38 |
+
SUPABASE_URL = "https://iiwbixdrrhejkthxygak.supabase.co"
|
39 |
+
SUPABASE_KEY = os.getenv("SUPA_KEY")
|
40 |
+
SUPABASE_ROLE_KEY = os.getenv("SUPA_SERVICE_KEY")
|
41 |
+
if not SUPABASE_KEY or not SUPABASE_ROLE_KEY:
|
42 |
+
raise ValueError("❌ SUPA_KEY or SUPA_SERVICE_KEY not set in environment!")
|
43 |
+
SUPABASE_HEADERS = {
|
44 |
+
"apikey": SUPABASE_KEY,
|
45 |
+
"Authorization": f"Bearer {SUPABASE_KEY}",
|
46 |
+
"Content-Type": "application/json"
|
47 |
+
}
|
48 |
+
SUPABASE_ROLE_HEADERS = {
|
49 |
+
"apikey": SUPABASE_ROLE_KEY,
|
50 |
+
"Authorization": f"Bearer {SUPABASE_ROLE_KEY}",
|
51 |
+
"Content-Type": "application/json"
|
52 |
+
}
|
53 |
+
|
54 |
+
# Rewrite API URL
|
55 |
+
REWRITE_API_URL = "https://habulaj-newapi.hf.space/rewrite-news"
|
56 |
+
|
57 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
58 |
+
log = logging.getLogger("news-analyze-api")
|
59 |
+
|
60 |
+
http_session = None
|
61 |
+
|
62 |
+
async def get_http_session():
|
63 |
+
global http_session
|
64 |
+
if http_session is None:
|
65 |
+
connector = aiohttp.TCPConnector(
|
66 |
+
limit=20,
|
67 |
+
limit_per_host=10,
|
68 |
+
ttl_dns_cache=300,
|
69 |
+
use_dns_cache=True,
|
70 |
+
keepalive_timeout=30,
|
71 |
+
enable_cleanup_closed=True
|
72 |
+
)
|
73 |
+
timeout = aiohttp.ClientTimeout(total=30, connect=5)
|
74 |
+
http_session = aiohttp.ClientSession(
|
75 |
+
connector=connector,
|
76 |
+
timeout=timeout,
|
77 |
+
headers={'User-Agent': 'NewsAnalyzeAPI/1.0 (https://example.com/contact)'}
|
78 |
+
)
|
79 |
+
return http_session
|
80 |
+
|
81 |
+
def load_inference_module():
|
82 |
+
"""Carrega o módulo inference.py dinamicamente"""
|
83 |
+
try:
|
84 |
+
# Assumindo que inference.py está no mesmo diretório ou em um caminho conhecido
|
85 |
+
inference_path = Path(__file__).parent / "inference.py" # Ajuste o caminho conforme necessário
|
86 |
+
|
87 |
+
if not inference_path.exists():
|
88 |
+
# Tenta outros caminhos possíveis
|
89 |
+
possible_paths = [
|
90 |
+
Path(__file__).parent.parent / "inference.py",
|
91 |
+
Path("./inference.py"),
|
92 |
+
Path("../inference.py")
|
93 |
+
]
|
94 |
+
|
95 |
+
for path in possible_paths:
|
96 |
+
if path.exists():
|
97 |
+
inference_path = path
|
98 |
+
break
|
99 |
+
else:
|
100 |
+
raise FileNotFoundError("inference.py não encontrado")
|
101 |
+
|
102 |
+
spec = importlib.util.spec_from_file_location("inference", inference_path)
|
103 |
+
inference_module = importlib.util.module_from_spec(spec)
|
104 |
+
spec.loader.exec_module(inference_module)
|
105 |
+
|
106 |
+
return inference_module
|
107 |
+
except Exception as e:
|
108 |
+
log.error(f"Erro ao carregar inference.py: {str(e)}")
|
109 |
+
return None
|
110 |
+
|
111 |
+
# Carrega o módulo na inicialização
|
112 |
+
inference_module = load_inference_module()
|
113 |
+
|
114 |
+
async def rewrite_article_direct(content: str) -> Optional[Dict[str, Any]]:
|
115 |
+
"""Reescreve o artigo chamando diretamente a função do inference.py"""
|
116 |
+
try:
|
117 |
+
if not inference_module:
|
118 |
+
log.error("Módulo inference não carregado, fallback para API HTTP")
|
119 |
+
return await rewrite_article_http(content)
|
120 |
+
|
121 |
+
log.info(f"Reescrevendo artigo diretamente: {len(content)} caracteres")
|
122 |
+
|
123 |
+
# Cria um objeto similar ao NewsRequest
|
124 |
+
class NewsRequest:
|
125 |
+
def __init__(self, content: str):
|
126 |
+
self.content = content
|
127 |
+
|
128 |
+
news_request = NewsRequest(content)
|
129 |
+
|
130 |
+
# Chama a função rewrite_news diretamente
|
131 |
+
result = await inference_module.rewrite_news(news_request)
|
132 |
+
|
133 |
+
# Converte o resultado para dicionário
|
134 |
+
rewritten_data = {
|
135 |
+
"title": result.title,
|
136 |
+
"subhead": result.subhead,
|
137 |
+
"content": result.content,
|
138 |
+
"sources": getattr(result, "sources", [])
|
139 |
+
}
|
140 |
+
|
141 |
+
# Validação básica da resposta
|
142 |
+
if all(key in rewritten_data and rewritten_data[key].strip() for key in ["title", "subhead", "content"]) and isinstance(rewritten_data["sources"], list):
|
143 |
+
log.info("Artigo reescrito com sucesso (chamada direta)")
|
144 |
+
return {
|
145 |
+
"success": True,
|
146 |
+
"data": rewritten_data,
|
147 |
+
"raw_response": str(rewritten_data),
|
148 |
+
"status_code": 200,
|
149 |
+
"method": "direct_call"
|
150 |
+
}
|
151 |
+
else:
|
152 |
+
log.error("Resposta da reescrita direta incompleta")
|
153 |
+
return {
|
154 |
+
"success": False,
|
155 |
+
"error": "Resposta incompleta",
|
156 |
+
"data": rewritten_data,
|
157 |
+
"raw_response": str(rewritten_data),
|
158 |
+
"status_code": 200,
|
159 |
+
"method": "direct_call",
|
160 |
+
"missing_keys": [key for key in ["title", "subhead", "content"] if not rewritten_data.get(key, "").strip()]
|
161 |
+
}
|
162 |
+
|
163 |
+
except Exception as e:
|
164 |
+
log.error(f"Erro na reescrita direta: {str(e)}")
|
165 |
+
log.info("Tentando fallback para API HTTP")
|
166 |
+
return await rewrite_article_http(content)
|
167 |
+
|
168 |
+
async def rewrite_article_http(content: str) -> Optional[Dict[str, Any]]:
|
169 |
+
"""Reescreve o artigo usando a API HTTP (função original)"""
|
170 |
+
try:
|
171 |
+
session = await get_http_session()
|
172 |
+
|
173 |
+
payload = {"content": content}
|
174 |
+
|
175 |
+
log.info(f"Enviando artigo para reescrita (HTTP): {len(content)} caracteres")
|
176 |
+
|
177 |
+
# Timeout maior para a API HTTP
|
178 |
+
timeout = aiohttp.ClientTimeout(total=120, connect=10) # 2 minutos
|
179 |
+
|
180 |
+
async with session.post(
|
181 |
+
REWRITE_API_URL,
|
182 |
+
json=payload,
|
183 |
+
headers={"Content-Type": "application/json"},
|
184 |
+
timeout=timeout
|
185 |
+
) as response:
|
186 |
+
|
187 |
+
# Log detalhado do status e headers
|
188 |
+
log.info(f"Status da resposta HTTP: {response.status}")
|
189 |
+
|
190 |
+
# Captura o body completo da resposta
|
191 |
+
response_text = await response.text()
|
192 |
+
log.info(f"Body completo da resposta HTTP: {response_text}")
|
193 |
+
|
194 |
+
if response.status == 200:
|
195 |
+
try:
|
196 |
+
# Tenta fazer parse do JSON
|
197 |
+
rewritten_data = json.loads(response_text)
|
198 |
+
|
199 |
+
# Validação básica da resposta
|
200 |
+
if all(key in rewritten_data for key in ["title", "subhead", "content"]):
|
201 |
+
log.info("Artigo reescrito com sucesso (HTTP)")
|
202 |
+
return {
|
203 |
+
"success": True,
|
204 |
+
"data": rewritten_data,
|
205 |
+
"raw_response": response_text,
|
206 |
+
"status_code": response.status,
|
207 |
+
"method": "http_call"
|
208 |
+
}
|
209 |
+
else:
|
210 |
+
log.error(f"Resposta HTTP incompleta. Chaves encontradas: {list(rewritten_data.keys())}")
|
211 |
+
return {
|
212 |
+
"success": False,
|
213 |
+
"error": "Resposta incompleta",
|
214 |
+
"data": rewritten_data,
|
215 |
+
"raw_response": response_text,
|
216 |
+
"status_code": response.status,
|
217 |
+
"method": "http_call",
|
218 |
+
"missing_keys": [key for key in ["title", "subhead", "content"] if key not in rewritten_data]
|
219 |
+
}
|
220 |
+
|
221 |
+
except json.JSONDecodeError as e:
|
222 |
+
log.error(f"Erro ao fazer parse do JSON: {str(e)}")
|
223 |
+
return {
|
224 |
+
"success": False,
|
225 |
+
"error": f"JSON inválido: {str(e)}",
|
226 |
+
"raw_response": response_text,
|
227 |
+
"status_code": response.status,
|
228 |
+
"method": "http_call"
|
229 |
+
}
|
230 |
+
else:
|
231 |
+
log.error(f"Erro na API HTTP: {response.status}")
|
232 |
+
return {
|
233 |
+
"success": False,
|
234 |
+
"error": f"HTTP {response.status}",
|
235 |
+
"raw_response": response_text,
|
236 |
+
"status_code": response.status,
|
237 |
+
"method": "http_call"
|
238 |
+
}
|
239 |
+
|
240 |
+
except asyncio.TimeoutError:
|
241 |
+
log.error("Timeout na API HTTP")
|
242 |
+
return {
|
243 |
+
"success": False,
|
244 |
+
"error": "Timeout",
|
245 |
+
"raw_response": "Timeout occurred",
|
246 |
+
"status_code": 0,
|
247 |
+
"method": "http_call"
|
248 |
+
}
|
249 |
+
except Exception as e:
|
250 |
+
log.error(f"Erro na API HTTP: {str(e)}")
|
251 |
+
return {
|
252 |
+
"success": False,
|
253 |
+
"error": str(e),
|
254 |
+
"raw_response": "Exception occurred",
|
255 |
+
"status_code": 0,
|
256 |
+
"method": "http_call"
|
257 |
+
}
|
258 |
+
|
259 |
+
async def rewrite_article(content: str) -> Optional[Dict[str, Any]]:
|
260 |
+
"""Reescreve o artigo - tenta chamada direta primeiro, depois HTTP"""
|
261 |
+
|
262 |
+
# Tenta chamada direta primeiro
|
263 |
+
result = await rewrite_article_direct(content)
|
264 |
+
|
265 |
+
# Se a chamada direta falhou e não foi um fallback, tenta HTTP
|
266 |
+
if not result or (not result.get("success") and result.get("method") == "direct_call"):
|
267 |
+
log.info("Chamada direta falhou, tentando API HTTP")
|
268 |
+
result = await rewrite_article_http(content)
|
269 |
+
|
270 |
+
return result
|
271 |
+
|
272 |
+
async def fetch_brazil_interest_news():
|
273 |
+
"""Busca uma notícia com brazil_interest=true e title_pt vazio"""
|
274 |
+
try:
|
275 |
+
session = await get_http_session()
|
276 |
+
url = f"{SUPABASE_URL}/rest/v1/news"
|
277 |
+
params = {
|
278 |
+
"brazil_interest": "eq.true",
|
279 |
+
"title_pt": "is.null",
|
280 |
+
"limit": "1",
|
281 |
+
"order": "created_at.asc"
|
282 |
+
}
|
283 |
+
|
284 |
+
async with session.get(url, headers=SUPABASE_HEADERS, params=params) as response:
|
285 |
+
if response.status != 200:
|
286 |
+
raise HTTPException(status_code=500, detail="Erro ao buscar notícia")
|
287 |
+
|
288 |
+
data = await response.json()
|
289 |
+
if not data:
|
290 |
+
raise HTTPException(status_code=404, detail="Nenhuma notícia com brazil_interest=true e title_pt vazio disponível")
|
291 |
+
|
292 |
+
return data[0]
|
293 |
+
except Exception as e:
|
294 |
+
raise HTTPException(status_code=500, detail=f"Erro Supabase: {str(e)}")
|
295 |
+
|
296 |
+
async def update_news_rewrite(news_id: int, rewritten_data: Dict[str, str]):
|
297 |
+
"""Atualiza a notícia com os dados reescritos e conta as fontes"""
|
298 |
+
try:
|
299 |
+
session = await get_http_session()
|
300 |
+
url = f"{SUPABASE_URL}/rest/v1/news"
|
301 |
+
params = {"id": f"eq.{news_id}"}
|
302 |
+
|
303 |
+
# Conta as fontes - se vazio ou None, usa 1 como padrão
|
304 |
+
sources_list = rewritten_data.get("sources", [])
|
305 |
+
sources_count = len(sources_list) if sources_list else 1
|
306 |
+
|
307 |
+
# Log para debug
|
308 |
+
log.info(f"Fontes encontradas: {sources_list}")
|
309 |
+
log.info(f"Contagem de fontes: {sources_count}")
|
310 |
+
|
311 |
+
payload = {
|
312 |
+
"title_pt": rewritten_data.get("title", ""),
|
313 |
+
"text_pt": rewritten_data.get("content", ""),
|
314 |
+
"subhead_pt": rewritten_data.get("subhead", ""),
|
315 |
+
"sources": sources_count # Adiciona a contagem de fontes
|
316 |
+
}
|
317 |
+
|
318 |
+
async with session.patch(url, headers=SUPABASE_ROLE_HEADERS, json=payload, params=params) as response:
|
319 |
+
if response.status not in [200, 201, 204]:
|
320 |
+
response_text = await response.text()
|
321 |
+
log.error(f"Erro ao atualizar notícia - Status: {response.status}, Response: {response_text}")
|
322 |
+
raise HTTPException(status_code=500, detail=f"Erro ao atualizar notícia - Status: {response.status}")
|
323 |
+
|
324 |
+
log.info(f"Notícia {news_id} atualizada com sucesso - Status: {response.status}, Fontes: {sources_count}")
|
325 |
+
|
326 |
+
except Exception as e:
|
327 |
+
log.error(f"Erro ao atualizar notícia {news_id}: {str(e)}")
|
328 |
+
raise HTTPException(status_code=500, detail=f"Erro ao atualizar: {str(e)}")
|
329 |
+
|
330 |
+
def fix_wikipedia_image_url(url: str) -> str:
|
331 |
+
if not url or not url.startswith('//upload.wikimedia.org'):
|
332 |
+
return url
|
333 |
+
|
334 |
+
if url.startswith('//'):
|
335 |
+
url = 'https:' + url
|
336 |
+
|
337 |
+
url = url.replace('/thumb/', '/')
|
338 |
+
parts = url.split('/')
|
339 |
+
if len(parts) >= 2:
|
340 |
+
filename = parts[-1]
|
341 |
+
if 'px-' in filename:
|
342 |
+
filename = filename.split('px-', 1)[1]
|
343 |
+
base_parts = parts[:-2]
|
344 |
+
url = '/'.join(base_parts) + '/' + filename
|
345 |
+
|
346 |
+
return url
|
347 |
+
|
348 |
+
def extract_birth_death_years(description: str) -> tuple[Optional[int], Optional[int]]:
|
349 |
+
if not description:
|
350 |
+
return None, None
|
351 |
+
|
352 |
+
pattern = r'\((?:born\s+)?(\d{4})(?:[–-](\d{4}))?\)'
|
353 |
+
match = re.search(pattern, description)
|
354 |
+
|
355 |
+
if match:
|
356 |
+
birth_year = int(match.group(1))
|
357 |
+
death_year = int(match.group(2)) if match.group(2) else None
|
358 |
+
if death_year is None:
|
359 |
+
death_year = 2025
|
360 |
+
return birth_year, death_year
|
361 |
+
|
362 |
+
return None, None
|
363 |
+
|
364 |
+
async def fetch_wikipedia_info(entity_name: str) -> Optional[Dict[str, Any]]:
|
365 |
+
try:
|
366 |
+
session = await get_http_session()
|
367 |
+
|
368 |
+
url = f"https://en.wikipedia.org/w/rest.php/v1/search/title"
|
369 |
+
params = {'q': entity_name, 'limit': 1}
|
370 |
+
|
371 |
+
async with session.get(url, params=params) as response:
|
372 |
+
if response.status != 200:
|
373 |
+
return None
|
374 |
+
|
375 |
+
data = await response.json()
|
376 |
+
|
377 |
+
if not data.get('pages'):
|
378 |
+
return None
|
379 |
+
|
380 |
+
page = data['pages'][0]
|
381 |
+
title = page.get('title', '')
|
382 |
+
description = page.get('description', '')
|
383 |
+
thumbnail = page.get('thumbnail', {})
|
384 |
+
|
385 |
+
birth_year, death_year = extract_birth_death_years(description)
|
386 |
+
|
387 |
+
image_url = thumbnail.get('url', '') if thumbnail else ''
|
388 |
+
if image_url:
|
389 |
+
image_url = fix_wikipedia_image_url(image_url)
|
390 |
+
|
391 |
+
return {
|
392 |
+
'title': title,
|
393 |
+
'birth_year': birth_year,
|
394 |
+
'death_year': death_year,
|
395 |
+
'image_url': image_url
|
396 |
+
}
|
397 |
+
|
398 |
+
except Exception as e:
|
399 |
+
log.error(f"Erro ao buscar Wikipedia: {str(e)}")
|
400 |
+
return None
|
401 |
+
|
402 |
+
def generate_poster_url(name: str, birth: int, death: int, image_url: str) -> str:
|
403 |
+
base_url = "https://habulaj-newapi.hf.space/cover/memoriam"
|
404 |
+
params = f"?image_url={quote(image_url)}&name={quote(name)}&birth={birth}&death={death}"
|
405 |
+
return base_url + params
|
406 |
+
|
407 |
+
def generate_news_poster_url(image_url: str, headline: str) -> str:
|
408 |
+
"""Gera URL do poster para notícias normais (não morte)"""
|
409 |
+
base_url = "https://habulaj-newapi.hf.space/cover/news"
|
410 |
+
params = f"?image_url={quote(image_url)}&headline={quote(headline)}"
|
411 |
+
return base_url + params
|
412 |
+
|
413 |
+
async def generate_poster_analysis(news_data: Dict[str, Any], rewritten_result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
414 |
+
"""Gera análise de poster baseado nos dados da notícia"""
|
415 |
+
try:
|
416 |
+
result = {}
|
417 |
+
image_url = news_data.get("image", "")
|
418 |
+
|
419 |
+
# Verifica se é morte e gera poster apropriado
|
420 |
+
if news_data.get("death_related") is True and news_data.get("entity_name"):
|
421 |
+
wikipedia_info = await fetch_wikipedia_info(news_data["entity_name"])
|
422 |
+
|
423 |
+
if wikipedia_info:
|
424 |
+
result["wikipedia_info"] = wikipedia_info
|
425 |
+
|
426 |
+
# Gera poster de morte apenas se tiver morte confirmada
|
427 |
+
if (wikipedia_info.get("death_year") and
|
428 |
+
wikipedia_info.get("birth_year")):
|
429 |
+
|
430 |
+
poster_url = generate_poster_url(
|
431 |
+
wikipedia_info["title"],
|
432 |
+
wikipedia_info["birth_year"],
|
433 |
+
wikipedia_info["death_year"],
|
434 |
+
wikipedia_info.get("image_url", image_url)
|
435 |
+
)
|
436 |
+
result["poster"] = poster_url
|
437 |
+
|
438 |
+
# Se não for morte, gera poster de notícia normal
|
439 |
+
if "poster" not in result and image_url:
|
440 |
+
# Usa headline reescrito se disponível, senão usa título original
|
441 |
+
headline_to_use = news_data.get("title_en", "") # fallback para título original
|
442 |
+
if (rewritten_result and
|
443 |
+
rewritten_result.get("success") and
|
444 |
+
rewritten_result.get("data") and
|
445 |
+
rewritten_result["data"].get("title")):
|
446 |
+
headline_to_use = rewritten_result["data"]["title"]
|
447 |
+
|
448 |
+
news_poster_url = generate_news_poster_url(image_url, headline_to_use)
|
449 |
+
result["poster"] = news_poster_url
|
450 |
+
|
451 |
+
return result
|
452 |
+
|
453 |
+
except Exception as e:
|
454 |
+
log.error(f"Erro ao gerar poster: {str(e)}")
|
455 |
+
return {}
|
456 |
+
|
457 |
+
app = FastAPI(title="News Analyze API")
|
458 |
+
router = APIRouter()
|
459 |
+
|
460 |
+
@router.post("/analyze")
|
461 |
+
async def analyze_endpoint():
|
462 |
+
# Busca notícia com brazil_interest=true e title_pt vazio
|
463 |
+
news_data = await fetch_brazil_interest_news()
|
464 |
+
|
465 |
+
title_en = news_data.get("title_en", "")
|
466 |
+
text_en = news_data.get("text_en", "")
|
467 |
+
news_id = news_data.get("id")
|
468 |
+
|
469 |
+
if not title_en.strip() or not text_en.strip():
|
470 |
+
raise HTTPException(status_code=400, detail="Title_en and text_en must not be empty.")
|
471 |
+
|
472 |
+
# Executa reescrita (tenta direta primeiro, depois HTTP)
|
473 |
+
rewritten_result = await rewrite_article(text_en)
|
474 |
+
|
475 |
+
# Log do resultado completo da reescrita
|
476 |
+
log.info(f"Resultado completo da reescrita: {json.dumps(rewritten_result, indent=2)}")
|
477 |
+
|
478 |
+
# Atualiza no banco de dados se reescrita foi bem-sucedida
|
479 |
+
if rewritten_result and rewritten_result.get("success") and rewritten_result.get("data"):
|
480 |
+
await update_news_rewrite(news_id, rewritten_result["data"])
|
481 |
+
|
482 |
+
# Gera análise de poster
|
483 |
+
poster_analysis = await generate_poster_analysis(news_data, rewritten_result)
|
484 |
+
|
485 |
+
# Prepara resultado final
|
486 |
+
result = {
|
487 |
+
"news_id": news_id,
|
488 |
+
"title_en": title_en,
|
489 |
+
"text_en": text_en,
|
490 |
+
"rewrite_result": rewritten_result,
|
491 |
+
"death_related": news_data.get("death_related", False),
|
492 |
+
"entity_name": news_data.get("entity_name", ""),
|
493 |
+
"entity_type": news_data.get("entity_type", ""),
|
494 |
+
"image": news_data.get("image", "")
|
495 |
+
}
|
496 |
+
|
497 |
+
# Adiciona informações do poster se disponíveis
|
498 |
+
if poster_analysis:
|
499 |
+
result.update(poster_analysis)
|
500 |
+
|
501 |
+
return result
|
502 |
+
|
503 |
+
app.include_router(router)
|
504 |
+
|
505 |
+
@app.on_event("shutdown")
|
506 |
+
async def shutdown_event():
|
507 |
+
global http_session
|
508 |
+
if http_session:
|
509 |
+
await http_session.close()
|
routers/curiosity.py
ADDED
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Query, HTTPException
|
2 |
+
from fastapi.responses import StreamingResponse
|
3 |
+
from PIL import Image, ImageDraw, ImageFont
|
4 |
+
from io import BytesIO
|
5 |
+
import requests
|
6 |
+
from typing import Optional
|
7 |
+
|
8 |
+
router = APIRouter()
|
9 |
+
|
10 |
+
def get_responsive_font_to_fit_height(text: str, font_path: str, max_width: int, max_height: int,
|
11 |
+
max_font_size: int = 48, min_font_size: int = 20) -> tuple[ImageFont.FreeTypeFont, list[str], int]:
|
12 |
+
temp_img = Image.new("RGB", (1, 1))
|
13 |
+
draw = ImageDraw.Draw(temp_img)
|
14 |
+
|
15 |
+
for font_size in range(max_font_size, min_font_size - 1, -1):
|
16 |
+
try:
|
17 |
+
font = ImageFont.truetype(font_path, font_size)
|
18 |
+
except:
|
19 |
+
font = ImageFont.load_default()
|
20 |
+
|
21 |
+
lines = wrap_text(text, font, max_width, draw)
|
22 |
+
line_height = int(font_size * 1.161)
|
23 |
+
total_height = len(lines) * line_height
|
24 |
+
|
25 |
+
if total_height <= max_height:
|
26 |
+
return font, lines, font_size
|
27 |
+
|
28 |
+
# Caso nenhum tamanho sirva, usar o mínimo mesmo assim
|
29 |
+
try:
|
30 |
+
font = ImageFont.truetype(font_path, min_font_size)
|
31 |
+
except:
|
32 |
+
font = ImageFont.load_default()
|
33 |
+
lines = wrap_text(text, font, max_width, draw)
|
34 |
+
return font, lines, min_font_size
|
35 |
+
|
36 |
+
def download_image_from_url(url: str) -> Image.Image:
|
37 |
+
response = requests.get(url)
|
38 |
+
if response.status_code != 200:
|
39 |
+
raise HTTPException(status_code=400, detail="Imagem não pôde ser baixada.")
|
40 |
+
return Image.open(BytesIO(response.content)).convert("RGBA")
|
41 |
+
|
42 |
+
def resize_and_crop_to_fill(img: Image.Image, target_width: int, target_height: int) -> Image.Image:
|
43 |
+
img_ratio = img.width / img.height
|
44 |
+
target_ratio = target_width / target_height
|
45 |
+
|
46 |
+
if img_ratio > target_ratio:
|
47 |
+
scale_height = target_height
|
48 |
+
scale_width = int(scale_height * img_ratio)
|
49 |
+
else:
|
50 |
+
scale_width = target_width
|
51 |
+
scale_height = int(scale_width / img_ratio)
|
52 |
+
|
53 |
+
img_resized = img.resize((scale_width, scale_height), Image.LANCZOS)
|
54 |
+
left = (scale_width - target_width) // 2
|
55 |
+
top = (scale_height - target_height) // 2
|
56 |
+
return img_resized.crop((left, top, left + target_width, top + target_height))
|
57 |
+
|
58 |
+
def create_black_gradient_overlay(width: int, height: int) -> Image.Image:
|
59 |
+
gradient = Image.new("RGBA", (width, height))
|
60 |
+
draw = ImageDraw.Draw(gradient)
|
61 |
+
for y in range(height):
|
62 |
+
opacity = int(255 * (y / height))
|
63 |
+
draw.line([(0, y), (width, y)], fill=(4, 4, 4, opacity))
|
64 |
+
return gradient
|
65 |
+
|
66 |
+
def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> list[str]:
|
67 |
+
lines = []
|
68 |
+
for raw_line in text.split("\n"):
|
69 |
+
words = raw_line.split()
|
70 |
+
current_line = ""
|
71 |
+
for word in words:
|
72 |
+
test_line = f"{current_line} {word}".strip()
|
73 |
+
if draw.textlength(test_line, font=font) <= max_width:
|
74 |
+
current_line = test_line
|
75 |
+
else:
|
76 |
+
if current_line:
|
77 |
+
lines.append(current_line)
|
78 |
+
current_line = word
|
79 |
+
if current_line:
|
80 |
+
lines.append(current_line)
|
81 |
+
elif not words:
|
82 |
+
lines.append("") # Linha vazia preserva \n\n
|
83 |
+
return lines
|
84 |
+
|
85 |
+
def get_responsive_font_and_lines(text: str, font_path: str, max_width: int, max_lines: int = 3,
|
86 |
+
max_font_size: int = 50, min_font_size: int = 20) -> tuple[ImageFont.FreeTypeFont, list[str], int]:
|
87 |
+
temp_img = Image.new("RGB", (1, 1))
|
88 |
+
temp_draw = ImageDraw.Draw(temp_img)
|
89 |
+
|
90 |
+
current_font_size = max_font_size
|
91 |
+
while current_font_size >= min_font_size:
|
92 |
+
try:
|
93 |
+
font = ImageFont.truetype(font_path, current_font_size)
|
94 |
+
except:
|
95 |
+
font = ImageFont.load_default()
|
96 |
+
|
97 |
+
lines = wrap_text(text, font, max_width, temp_draw)
|
98 |
+
if len(lines) <= max_lines:
|
99 |
+
return font, lines, current_font_size
|
100 |
+
current_font_size -= 1
|
101 |
+
|
102 |
+
try:
|
103 |
+
font = ImageFont.truetype(font_path, min_font_size)
|
104 |
+
except:
|
105 |
+
font = ImageFont.load_default()
|
106 |
+
lines = wrap_text(text, font, max_width, temp_draw)
|
107 |
+
return font, lines, min_font_size
|
108 |
+
|
109 |
+
def generate_slide_1(image_url: Optional[str], headline: Optional[str]) -> Image.Image:
|
110 |
+
width, height = 1080, 1350
|
111 |
+
canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255))
|
112 |
+
|
113 |
+
if image_url:
|
114 |
+
try:
|
115 |
+
img = download_image_from_url(image_url)
|
116 |
+
filled_img = resize_and_crop_to_fill(img, width, height)
|
117 |
+
canvas.paste(filled_img, (0, 0))
|
118 |
+
except Exception as e:
|
119 |
+
raise HTTPException(status_code=400, detail=f"Erro ao processar imagem de fundo: {e}")
|
120 |
+
|
121 |
+
# Gradiente
|
122 |
+
gradient_overlay = create_black_gradient_overlay(width, height)
|
123 |
+
canvas = Image.alpha_composite(canvas, gradient_overlay)
|
124 |
+
|
125 |
+
draw = ImageDraw.Draw(canvas)
|
126 |
+
|
127 |
+
# Logo no topo
|
128 |
+
try:
|
129 |
+
logo = Image.open("recurvecuriosity.png").convert("RGBA").resize((368, 29))
|
130 |
+
canvas.paste(logo, (66, 74), logo)
|
131 |
+
except Exception as e:
|
132 |
+
raise HTTPException(status_code=500, detail=f"Erro ao carregar recurvecuriosity.png: {e}")
|
133 |
+
|
134 |
+
# Imagem arrastar no rodapé
|
135 |
+
try:
|
136 |
+
arrow = Image.open("arrastar.png").convert("RGBA").resize((355, 37))
|
137 |
+
canvas.paste(arrow, (66, 1240), arrow)
|
138 |
+
except Exception as e:
|
139 |
+
raise HTTPException(status_code=500, detail=f"Erro ao carregar arrastar.png: {e}")
|
140 |
+
|
141 |
+
# Texto headline acima da imagem arrastar
|
142 |
+
if headline:
|
143 |
+
font_path = "fonts/Montserrat-Bold.ttf"
|
144 |
+
max_width = 945
|
145 |
+
max_lines = 3
|
146 |
+
try:
|
147 |
+
font, lines, font_size = get_responsive_font_and_lines(
|
148 |
+
headline, font_path, max_width, max_lines=max_lines,
|
149 |
+
max_font_size=50, min_font_size=20
|
150 |
+
)
|
151 |
+
line_height = int(font_size * 1.161)
|
152 |
+
except Exception as e:
|
153 |
+
raise HTTPException(status_code=500, detail=f"Erro ao processar fonte/headline: {e}")
|
154 |
+
|
155 |
+
total_text_height = len(lines) * line_height
|
156 |
+
start_y = 1240 - 16 - total_text_height
|
157 |
+
x = (width - max_width) // 2
|
158 |
+
for i, line in enumerate(lines):
|
159 |
+
y = start_y + i * line_height
|
160 |
+
draw.text((x, y), line, font=font, fill=(255, 255, 255))
|
161 |
+
|
162 |
+
return canvas
|
163 |
+
|
164 |
+
def generate_slide_2(image_url: Optional[str], headline: Optional[str]) -> Image.Image:
|
165 |
+
width, height = 1080, 1350
|
166 |
+
canvas = Image.new("RGBA", (width, height), color=(4, 4, 4, 255))
|
167 |
+
draw = ImageDraw.Draw(canvas)
|
168 |
+
|
169 |
+
# === Imagem principal ===
|
170 |
+
if image_url:
|
171 |
+
try:
|
172 |
+
img = download_image_from_url(image_url)
|
173 |
+
resized = resize_and_crop_to_fill(img, 1080, 830)
|
174 |
+
canvas.paste(resized, (0, 0))
|
175 |
+
except Exception as e:
|
176 |
+
raise HTTPException(status_code=400, detail=f"Erro ao processar imagem do slide 2: {e}")
|
177 |
+
|
178 |
+
# === Headline ===
|
179 |
+
if headline:
|
180 |
+
font_path = "fonts/Montserrat-SemiBold.ttf"
|
181 |
+
max_width = 945
|
182 |
+
top_y = 830 + 70
|
183 |
+
bottom_padding = 70 # Alterado de 70 para 70 (já estava correto)
|
184 |
+
available_height = height - top_y - bottom_padding
|
185 |
+
|
186 |
+
try:
|
187 |
+
font, lines, font_size = get_responsive_font_to_fit_height(
|
188 |
+
headline,
|
189 |
+
font_path=font_path,
|
190 |
+
max_width=max_width,
|
191 |
+
max_height=available_height,
|
192 |
+
max_font_size=48,
|
193 |
+
min_font_size=20
|
194 |
+
)
|
195 |
+
line_height = int(font_size * 1.161)
|
196 |
+
except Exception as e:
|
197 |
+
raise HTTPException(status_code=500, detail=f"Erro ao processar texto do slide 2: {e}")
|
198 |
+
|
199 |
+
x = (width - max_width) // 2
|
200 |
+
for i, line in enumerate(lines):
|
201 |
+
y = top_y + i * line_height
|
202 |
+
draw.text((x, y), line, font=font, fill=(255, 255, 255))
|
203 |
+
|
204 |
+
return canvas
|
205 |
+
|
206 |
+
def generate_slide_3(image_url: Optional[str], headline: Optional[str]) -> Image.Image:
|
207 |
+
width, height = 1080, 1350
|
208 |
+
canvas = Image.new("RGBA", (width, height), color=(4, 4, 4, 255))
|
209 |
+
draw = ImageDraw.Draw(canvas)
|
210 |
+
|
211 |
+
# === Imagem com cantos arredondados à esquerda ===
|
212 |
+
if image_url:
|
213 |
+
try:
|
214 |
+
img = download_image_from_url(image_url)
|
215 |
+
resized = resize_and_crop_to_fill(img, 990, 750)
|
216 |
+
|
217 |
+
# Máscara arredondando cantos esquerdos
|
218 |
+
mask = Image.new("L", (990, 750), 0)
|
219 |
+
mask_draw = ImageDraw.Draw(mask)
|
220 |
+
mask_draw.rectangle((25, 0, 990, 750), fill=255)
|
221 |
+
mask_draw.pieslice([0, 0, 50, 50], 180, 270, fill=255)
|
222 |
+
mask_draw.pieslice([0, 700, 50, 750], 90, 180, fill=255)
|
223 |
+
mask_draw.rectangle((0, 25, 25, 725), fill=255)
|
224 |
+
|
225 |
+
canvas.paste(resized, (90, 422), mask)
|
226 |
+
|
227 |
+
except Exception as e:
|
228 |
+
raise HTTPException(status_code=400, detail=f"Erro ao processar imagem do slide 3: {e}")
|
229 |
+
|
230 |
+
# === Headline acima da imagem ===
|
231 |
+
if headline:
|
232 |
+
font_path = "fonts/Montserrat-SemiBold.ttf"
|
233 |
+
max_width = 945
|
234 |
+
image_top_y = 422
|
235 |
+
spacing = 50
|
236 |
+
bottom_of_text = image_top_y - spacing
|
237 |
+
safe_top = 70 # Alterado de 70 para 70 (já estava correto)
|
238 |
+
available_height = bottom_of_text - safe_top
|
239 |
+
|
240 |
+
font_size = 48
|
241 |
+
while font_size >= 20:
|
242 |
+
try:
|
243 |
+
font = ImageFont.truetype(font_path, font_size)
|
244 |
+
except:
|
245 |
+
font = ImageFont.load_default()
|
246 |
+
|
247 |
+
lines = wrap_text(headline, font, max_width, draw)
|
248 |
+
line_height = int(font_size * 1.161)
|
249 |
+
total_text_height = len(lines) * line_height
|
250 |
+
start_y = bottom_of_text - total_text_height
|
251 |
+
|
252 |
+
if start_y >= safe_top:
|
253 |
+
break
|
254 |
+
|
255 |
+
font_size -= 1
|
256 |
+
|
257 |
+
try:
|
258 |
+
font = ImageFont.truetype(font_path, font_size)
|
259 |
+
except:
|
260 |
+
font = ImageFont.load_default()
|
261 |
+
|
262 |
+
x = 90
|
263 |
+
for i, line in enumerate(lines):
|
264 |
+
y = start_y + i * line_height
|
265 |
+
draw.text((x, y), line, font=font, fill=(255, 255, 255))
|
266 |
+
|
267 |
+
return canvas
|
268 |
+
|
269 |
+
def generate_slide_4(image_url: Optional[str], headline: Optional[str]) -> Image.Image:
|
270 |
+
width, height = 1080, 1350
|
271 |
+
canvas = Image.new("RGBA", (width, height), color=(4, 4, 4, 255))
|
272 |
+
draw = ImageDraw.Draw(canvas)
|
273 |
+
|
274 |
+
# === Imagem com cantos arredondados à esquerda ===
|
275 |
+
if image_url:
|
276 |
+
try:
|
277 |
+
img = download_image_from_url(image_url)
|
278 |
+
resized = resize_and_crop_to_fill(img, 990, 750)
|
279 |
+
|
280 |
+
# Máscara com cantos arredondados à esquerda
|
281 |
+
mask = Image.new("L", (990, 750), 0)
|
282 |
+
mask_draw = ImageDraw.Draw(mask)
|
283 |
+
mask_draw.rectangle((25, 0, 990, 750), fill=255)
|
284 |
+
mask_draw.pieslice([0, 0, 50, 50], 180, 270, fill=255)
|
285 |
+
mask_draw.pieslice([0, 700, 50, 750], 90, 180, fill=255)
|
286 |
+
mask_draw.rectangle((0, 25, 25, 725), fill=255)
|
287 |
+
|
288 |
+
canvas.paste(resized, (90, 178), mask)
|
289 |
+
|
290 |
+
except Exception as e:
|
291 |
+
raise HTTPException(status_code=400, detail=f"Erro ao processar imagem do slide 4: {e}")
|
292 |
+
|
293 |
+
# === Headline abaixo da imagem ===
|
294 |
+
if headline:
|
295 |
+
font_path = "fonts/Montserrat-SemiBold.ttf"
|
296 |
+
max_width = 945
|
297 |
+
top_of_text = 178 + 750 + 50 # Y da imagem + altura + espaçamento
|
298 |
+
safe_bottom = 70 # Alterado de 50 para 70
|
299 |
+
available_height = height - top_of_text - safe_bottom
|
300 |
+
|
301 |
+
try:
|
302 |
+
font, lines, font_size = get_responsive_font_to_fit_height(
|
303 |
+
headline,
|
304 |
+
font_path=font_path,
|
305 |
+
max_width=max_width,
|
306 |
+
max_height=available_height,
|
307 |
+
max_font_size=48,
|
308 |
+
min_font_size=20
|
309 |
+
)
|
310 |
+
line_height = int(font_size * 1.161)
|
311 |
+
except Exception as e:
|
312 |
+
raise HTTPException(status_code=500, detail=f"Erro ao processar texto do slide 4: {e}")
|
313 |
+
|
314 |
+
x = 90
|
315 |
+
for i, line in enumerate(lines):
|
316 |
+
y = top_of_text + i * line_height
|
317 |
+
draw.text((x, y), line, font=font, fill=(255, 255, 255))
|
318 |
+
|
319 |
+
return canvas
|
320 |
+
|
321 |
+
def generate_slide_5(image_url: Optional[str], headline: Optional[str]) -> Image.Image:
|
322 |
+
width, height = 1080, 1350
|
323 |
+
canvas = Image.new("RGBA", (width, height), color=(4, 4, 4, 255))
|
324 |
+
draw = ImageDraw.Draw(canvas)
|
325 |
+
|
326 |
+
image_w, image_h = 900, 748
|
327 |
+
image_x = 90
|
328 |
+
image_y = 100
|
329 |
+
|
330 |
+
# === Imagem com cantos totalmente arredondados ===
|
331 |
+
if image_url:
|
332 |
+
try:
|
333 |
+
img = download_image_from_url(image_url)
|
334 |
+
resized = resize_and_crop_to_fill(img, image_w, image_h)
|
335 |
+
|
336 |
+
# Máscara com cantos 25px arredondados (todos os cantos)
|
337 |
+
radius = 25
|
338 |
+
mask = Image.new("L", (image_w, image_h), 0)
|
339 |
+
mask_draw = ImageDraw.Draw(mask)
|
340 |
+
mask_draw.rounded_rectangle((0, 0, image_w, image_h), radius=radius, fill=255)
|
341 |
+
|
342 |
+
canvas.paste(resized, (image_x, image_y), mask)
|
343 |
+
|
344 |
+
except Exception as e:
|
345 |
+
raise HTTPException(status_code=400, detail=f"Erro ao processar imagem do slide 5: {e}")
|
346 |
+
|
347 |
+
# === Texto abaixo da imagem ===
|
348 |
+
if headline:
|
349 |
+
font_path = "fonts/Montserrat-SemiBold.ttf"
|
350 |
+
max_width = 945
|
351 |
+
top_of_text = image_y + image_h + 50
|
352 |
+
safe_bottom = 70 # Alterado de 50 para 70
|
353 |
+
available_height = height - top_of_text - safe_bottom
|
354 |
+
|
355 |
+
try:
|
356 |
+
font, lines, font_size = get_responsive_font_to_fit_height(
|
357 |
+
headline,
|
358 |
+
font_path=font_path,
|
359 |
+
max_width=max_width,
|
360 |
+
max_height=available_height,
|
361 |
+
max_font_size=48,
|
362 |
+
min_font_size=20
|
363 |
+
)
|
364 |
+
line_height = int(font_size * 1.161)
|
365 |
+
except Exception as e:
|
366 |
+
raise HTTPException(status_code=500, detail=f"Erro ao processar texto do slide 5: {e}")
|
367 |
+
|
368 |
+
x = (width - max_width) // 2 # Centralizado horizontalmente
|
369 |
+
for i, line in enumerate(lines):
|
370 |
+
y = top_of_text + i * line_height
|
371 |
+
draw.text((x, y), line, font=font, fill=(255, 255, 255))
|
372 |
+
|
373 |
+
return canvas
|
374 |
+
|
375 |
+
def generate_black_canvas() -> Image.Image:
|
376 |
+
return Image.new("RGB", (1080, 1350), color=(4, 4, 4))
|
377 |
+
|
378 |
+
@router.get("/cover/curiosity")
|
379 |
+
def get_curiosity_image(
|
380 |
+
image_url: Optional[str] = Query(None, description="URL da imagem de fundo"),
|
381 |
+
headline: Optional[str] = Query(None, description="Texto da curiosidade"),
|
382 |
+
slide: int = Query(1, ge=1, le=5, description="Número do slide (1 a 5)")
|
383 |
+
):
|
384 |
+
try:
|
385 |
+
if slide == 1:
|
386 |
+
final_image = generate_slide_1(image_url, headline)
|
387 |
+
elif slide == 2:
|
388 |
+
final_image = generate_slide_2(image_url, headline)
|
389 |
+
elif slide == 3:
|
390 |
+
final_image = generate_slide_3(image_url, headline)
|
391 |
+
elif slide == 4:
|
392 |
+
final_image = generate_slide_4(image_url, headline)
|
393 |
+
elif slide == 5:
|
394 |
+
final_image = generate_slide_5(image_url, headline)
|
395 |
+
else:
|
396 |
+
final_image = generate_black_canvas()
|
397 |
+
|
398 |
+
buffer = BytesIO()
|
399 |
+
final_image.convert("RGB").save(buffer, format="PNG")
|
400 |
+
buffer.seek(0)
|
401 |
+
return StreamingResponse(buffer, media_type="image/png")
|
402 |
+
except Exception as e:
|
403 |
+
raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}")
|
routers/filter.py
ADDED
@@ -0,0 +1,548 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import sys
|
3 |
+
import re
|
4 |
+
import json
|
5 |
+
import time
|
6 |
+
import logging
|
7 |
+
import gc
|
8 |
+
import asyncio
|
9 |
+
import aiohttp
|
10 |
+
import random
|
11 |
+
from typing import Optional, Dict, Any
|
12 |
+
from fastapi import FastAPI, APIRouter, HTTPException
|
13 |
+
from pydantic import BaseModel
|
14 |
+
from google import genai
|
15 |
+
from google.genai import types
|
16 |
+
from newspaper import Article
|
17 |
+
import trafilatura
|
18 |
+
|
19 |
+
# Supabase Config
|
20 |
+
SUPABASE_URL = "https://iiwbixdrrhejkthxygak.supabase.co"
|
21 |
+
SUPABASE_KEY = os.getenv("SUPA_KEY")
|
22 |
+
SUPABASE_ROLE_KEY = os.getenv("SUPA_SERVICE_KEY")
|
23 |
+
if not SUPABASE_KEY or not SUPABASE_ROLE_KEY:
|
24 |
+
raise ValueError("❌ SUPA_KEY or SUPA_SERVICE_KEY not set in environment!")
|
25 |
+
SUPABASE_HEADERS = {
|
26 |
+
"apikey": SUPABASE_KEY,
|
27 |
+
"Authorization": f"Bearer {SUPABASE_KEY}",
|
28 |
+
"Content-Type": "application/json"
|
29 |
+
}
|
30 |
+
SUPABASE_ROLE_HEADERS = {
|
31 |
+
"apikey": SUPABASE_ROLE_KEY,
|
32 |
+
"Authorization": f"Bearer {SUPABASE_ROLE_KEY}",
|
33 |
+
"Content-Type": "application/json"
|
34 |
+
}
|
35 |
+
|
36 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
37 |
+
log = logging.getLogger("news-filter-api")
|
38 |
+
|
39 |
+
http_session = None
|
40 |
+
|
41 |
+
# Lista de User-Agents realistas para rotacionar
|
42 |
+
USER_AGENTS = [
|
43 |
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
44 |
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
45 |
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0',
|
46 |
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15',
|
47 |
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edge/91.0.864.59'
|
48 |
+
]
|
49 |
+
|
50 |
+
async def get_http_session():
|
51 |
+
global http_session
|
52 |
+
if http_session is None:
|
53 |
+
connector = aiohttp.TCPConnector(
|
54 |
+
limit=20,
|
55 |
+
limit_per_host=10,
|
56 |
+
ttl_dns_cache=300,
|
57 |
+
use_dns_cache=True,
|
58 |
+
keepalive_timeout=30,
|
59 |
+
enable_cleanup_closed=True
|
60 |
+
)
|
61 |
+
timeout = aiohttp.ClientTimeout(total=30, connect=5)
|
62 |
+
http_session = aiohttp.ClientSession(
|
63 |
+
connector=connector,
|
64 |
+
timeout=timeout
|
65 |
+
)
|
66 |
+
return http_session
|
67 |
+
|
68 |
+
def get_realistic_headers():
|
69 |
+
"""Retorna headers realistas para evitar bloqueios"""
|
70 |
+
return {
|
71 |
+
'User-Agent': random.choice(USER_AGENTS),
|
72 |
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
73 |
+
'Accept-Language': 'en-US,en;q=0.5',
|
74 |
+
'Accept-Encoding': 'gzip, deflate, br',
|
75 |
+
'DNT': '1',
|
76 |
+
'Connection': 'keep-alive',
|
77 |
+
'Upgrade-Insecure-Requests': '1',
|
78 |
+
'Cache-Control': 'max-age=0'
|
79 |
+
}
|
80 |
+
|
81 |
+
async def extract_article_text(url: str) -> str:
|
82 |
+
"""Extrai o texto completo de uma notícia usando newspaper3k com fallback para trafilatura"""
|
83 |
+
try:
|
84 |
+
# Método 1: newspaper3k com headers realistas
|
85 |
+
try:
|
86 |
+
article = Article(url)
|
87 |
+
article.config.browser_user_agent = random.choice(USER_AGENTS)
|
88 |
+
article.config.request_timeout = 10
|
89 |
+
article.config.number_threads = 1
|
90 |
+
|
91 |
+
article.download()
|
92 |
+
article.parse()
|
93 |
+
|
94 |
+
if article.text and len(article.text.strip()) > 100:
|
95 |
+
return article.text.strip()
|
96 |
+
|
97 |
+
except Exception:
|
98 |
+
pass
|
99 |
+
|
100 |
+
# Método 2: trafilatura como fallback
|
101 |
+
session = await get_http_session()
|
102 |
+
headers = get_realistic_headers()
|
103 |
+
|
104 |
+
# Adiciona um pequeno delay para parecer mais humano
|
105 |
+
await asyncio.sleep(random.uniform(1, 3))
|
106 |
+
|
107 |
+
async with session.get(url, headers=headers) as response:
|
108 |
+
if response.status == 200:
|
109 |
+
html = await response.text()
|
110 |
+
extracted_text = trafilatura.extract(html)
|
111 |
+
|
112 |
+
if extracted_text and len(extracted_text.strip()) > 100:
|
113 |
+
return extracted_text.strip()
|
114 |
+
|
115 |
+
return ""
|
116 |
+
|
117 |
+
except Exception as e:
|
118 |
+
log.error(f"Erro ao extrair texto da URL {url}: {str(e)}")
|
119 |
+
return ""
|
120 |
+
|
121 |
+
async def fetch_unused_news():
|
122 |
+
"""Busca uma notícia não usada do Supabase"""
|
123 |
+
try:
|
124 |
+
session = await get_http_session()
|
125 |
+
url = f"{SUPABASE_URL}/rest/v1/news_extraction"
|
126 |
+
params = {
|
127 |
+
"used": "eq.false",
|
128 |
+
"limit": "1",
|
129 |
+
"order": "created_at.asc"
|
130 |
+
}
|
131 |
+
|
132 |
+
async with session.get(url, headers=SUPABASE_HEADERS, params=params) as response:
|
133 |
+
if response.status != 200:
|
134 |
+
raise HTTPException(status_code=500, detail="Erro ao buscar notícia")
|
135 |
+
|
136 |
+
data = await response.json()
|
137 |
+
if not data:
|
138 |
+
raise HTTPException(status_code=404, detail="Nenhuma notícia disponível")
|
139 |
+
|
140 |
+
return data[0]
|
141 |
+
except Exception as e:
|
142 |
+
raise HTTPException(status_code=500, detail=f"Erro Supabase: {str(e)}")
|
143 |
+
|
144 |
+
async def fetch_last_50_titles():
|
145 |
+
"""Busca os últimos 50 títulos da tabela news ordenados por created_at"""
|
146 |
+
try:
|
147 |
+
session = await get_http_session()
|
148 |
+
url = f"{SUPABASE_URL}/rest/v1/news"
|
149 |
+
params = {
|
150 |
+
"select": "title_pt",
|
151 |
+
"limit": "50",
|
152 |
+
"order": "created_at.desc"
|
153 |
+
}
|
154 |
+
|
155 |
+
async with session.get(url, headers=SUPABASE_HEADERS, params=params) as response:
|
156 |
+
if response.status != 200:
|
157 |
+
log.warning("Erro ao buscar títulos anteriores")
|
158 |
+
return []
|
159 |
+
|
160 |
+
data = await response.json()
|
161 |
+
titles = [item.get("title_pt", "") for item in data if item.get("title_pt")]
|
162 |
+
return titles
|
163 |
+
except Exception as e:
|
164 |
+
log.warning(f"Erro ao buscar últimos títulos: {str(e)}")
|
165 |
+
return []
|
166 |
+
|
167 |
+
async def insert_news_to_db(title: str, text: str, news_id: str, url: str, image_url: str, filters: dict):
|
168 |
+
"""Insere notícia na tabela news com dados originais e filtros"""
|
169 |
+
try:
|
170 |
+
session = await get_http_session()
|
171 |
+
supabase_url = f"{SUPABASE_URL}/rest/v1/news"
|
172 |
+
|
173 |
+
payload = {
|
174 |
+
"title_en": title,
|
175 |
+
"text_en": text,
|
176 |
+
"news_id": news_id,
|
177 |
+
"url": url,
|
178 |
+
"image": image_url,
|
179 |
+
"death_related": filters.get("death_related", False),
|
180 |
+
"political_related": filters.get("political_related", False),
|
181 |
+
"woke_related": filters.get("woke_related", False),
|
182 |
+
"spoilers": filters.get("spoilers", False),
|
183 |
+
"sensitive_theme": filters.get("sensitive_theme", False),
|
184 |
+
"contains_video": filters.get("contains_video", False),
|
185 |
+
"is_news_content": filters.get("is_news_content", True),
|
186 |
+
"relevance": filters.get("relevance", ""),
|
187 |
+
"brazil_interest": filters.get("brazil_interest", False),
|
188 |
+
"breaking_news": filters.get("breaking_news", False),
|
189 |
+
"audience_age_rating": filters.get("audience_age_rating", ""),
|
190 |
+
"regional_focus": filters.get("regional_focus", ""),
|
191 |
+
"country_focus": filters.get("country_focus", ""),
|
192 |
+
"ideological_alignment": filters.get("ideological_alignment", ""),
|
193 |
+
"entity_type": filters.get("entity_type", ""),
|
194 |
+
"entity_name": filters.get("entity_name", ""),
|
195 |
+
"duplication": filters.get("duplication", False)
|
196 |
+
}
|
197 |
+
|
198 |
+
async with session.post(supabase_url, headers=SUPABASE_ROLE_HEADERS, json=payload) as response:
|
199 |
+
if response.status not in [200, 201]:
|
200 |
+
response_text = await response.text()
|
201 |
+
raise HTTPException(status_code=500, detail=f"Erro ao inserir notícia: {response.status} - {response_text}")
|
202 |
+
|
203 |
+
except Exception as e:
|
204 |
+
raise HTTPException(status_code=500, detail=f"Erro ao inserir: {str(e)}")
|
205 |
+
|
206 |
+
async def mark_news_as_used(news_id: str):
|
207 |
+
"""Marca notícia como usada - SEMPRE deve funcionar para evitar loops infinitos"""
|
208 |
+
try:
|
209 |
+
session = await get_http_session()
|
210 |
+
url = f"{SUPABASE_URL}/rest/v1/news_extraction"
|
211 |
+
params = {"news_id": f"eq.{news_id}"}
|
212 |
+
|
213 |
+
payload = {"used": True}
|
214 |
+
|
215 |
+
async with session.patch(url, headers=SUPABASE_ROLE_HEADERS, json=payload, params=params) as response:
|
216 |
+
if response.status not in [200, 201, 204]:
|
217 |
+
log.warning(f"Erro ao marcar {news_id} como usada, mas continuando...")
|
218 |
+
|
219 |
+
except Exception as e:
|
220 |
+
log.warning(f"Erro ao atualizar notícia {news_id}: {str(e)}")
|
221 |
+
|
222 |
+
def extract_json(text):
|
223 |
+
match = re.search(r'\{.*\}', text, flags=re.DOTALL)
|
224 |
+
return match.group(0) if match else text
|
225 |
+
|
226 |
+
def ensure_filter_order(filter_dict: Dict[str, Any]) -> Dict[str, Any]:
|
227 |
+
ordered_keys = [
|
228 |
+
"death_related", "political_related", "woke_related", "spoilers",
|
229 |
+
"sensitive_theme", "contains_video", "is_news_content", "relevance",
|
230 |
+
"brazil_interest", "breaking_news", "audience_age_rating", "regional_focus",
|
231 |
+
"country_focus", "ideological_alignment", "entity_type", "entity_name", "duplication"
|
232 |
+
]
|
233 |
+
|
234 |
+
return {key: filter_dict[key] for key in ordered_keys if key in filter_dict}
|
235 |
+
|
236 |
+
async def filter_news(title: str, content: str, last_titles: list) -> dict:
|
237 |
+
try:
|
238 |
+
client = genai.Client(
|
239 |
+
api_key=os.environ.get("GEMINI_API_KEY"),
|
240 |
+
)
|
241 |
+
|
242 |
+
model = "gemini-2.5-flash-lite"
|
243 |
+
|
244 |
+
# Instruções do sistema
|
245 |
+
SYSTEM_INSTRUCTIONS = """
|
246 |
+
Analyze the news title and content, and return the filters in JSON format with the defined fields.
|
247 |
+
Please respond ONLY with the JSON filter, do NOT add any explanations, system messages, or extra text.
|
248 |
+
|
249 |
+
- death_related (true | false): Whether the news involves the real-life death of a person. Does not include fictional character deaths or deaths within stories.
|
250 |
+
- political_related (true | false): Related to real-world politics (governments, elections, politicians, or official decisions). Not about political storylines in fiction.
|
251 |
+
- woke_related (true | false): Involves social issues like inclusion, diversity, racism, gender, LGBTQIA+, etc.
|
252 |
+
- spoilers (true | false): Reveals important plot points (e.g., character deaths, endings, major twists).
|
253 |
+
- sensitive_theme (true | false): Covers sensitive or disturbing topics like suicide, abuse, violence, or tragedy.
|
254 |
+
- contains_video (true | false): The news includes an embedded video (e.g., trailer, teaser, interview, video report).
|
255 |
+
- is_news_content (true | false): Whether the content is actual news reporting. True for breaking news, announcements, factual reports. False for reviews, opinion pieces, lists, rankings, recommendations, critiques, analysis, or editorial content.
|
256 |
+
- relevance ("low" | "medium" | "high" | "viral"): The expected public interest or impact of the news.
|
257 |
+
- brazil_interest (true | false): Whether the topic is directly related to Brazil or relevant to Brazilian audiences.
|
258 |
+
- breaking_news (true | false): The content is urgent or part of a recent and unfolding event.
|
259 |
+
- audience_age_rating ("L" | 10 | 12 | 14 | 16 | 18): Content rating based on Brazilian standards.
|
260 |
+
- regional_focus ("global" | "americas" | "europe" | "asia" | "africa" | "middle_east" | "oceania"): The main geographic region the news relates to.
|
261 |
+
- country_focus (ISO 3166-1 alpha-2 code like "br", "us", "fr", "jp" or null): The specific country the news is about, if applicable.
|
262 |
+
- ideological_alignment ("left" | "center-left" | "center" | "center-right" | "right" | "apolitical"): The perceived political bias of the article.
|
263 |
+
- entity_type ("movie" | "series" | "event" | "person" | "place" | "other"): The type of main subject mentioned in the news.
|
264 |
+
- entity_name (string): The name of the person, title, event, or topic the article is primarily about.
|
265 |
+
- duplication (true | false): Whether the current news is a duplicate or highly similar to any of the previously published news titles.
|
266 |
+
"""
|
267 |
+
|
268 |
+
# Formata os últimos títulos para incluir no prompt - aumentado para 25 títulos
|
269 |
+
last_titles_formatted = "\n- ".join(last_titles[:25]) if last_titles else "No previous titles available"
|
270 |
+
|
271 |
+
# Primeiro exemplo - NOTÍCIA REAL
|
272 |
+
EXAMPLE_INPUT_1 = f"""Title: 'The Gilded Age' Renewed for Season 4 at HBO — Everything We Know So Far
|
273 |
+
Content: The Gilded Age will return. HBO announced on Monday, July 28, that the series has been renewed for Season 4. This comes after the release of Season 3 Episode 6 on Sunday, July 27. There are two episodes left to go in the third season. The Season 3 finale will air on Sunday, August 10, on HBO. According to HBO, total premiere-night viewing for the third season has grown for five consecutive weeks, culminating in a 20 percent growth compared to last season. Fan engagement has also climbed, with social chatter rising nearly 60 percent week over week. The show has also received its most critical acclaim to date with Season 3, its highest-stakes season so far. In the July 27 episode, the series that's known for its low stakes but high-camp drama, a character was seemingly killed off in violent (for The Gilded Age) fashion. The show is already Emmy-winning. Production designer Bob Shaw took home an Emmy for
|
274 |
+
Last titles:
|
275 |
+
- 'Quarteto Fantástico: Primeiros Passos' dispara para arrecadar US$ 118 milhões nas bilheterias dos EUA e US$ 218 milhões globalmente
|
276 |
+
- Bilheteria: 'Quarteto Fantástico: Primeiros Passos' sobe para US$ 218 milhões globalmente, 'Superman' e 'F1' ultrapassam US$ 500 milhões
|
277 |
+
- Reboot de 'Quarteto Fantástico' da Marvel ultrapassa US$ 200 milhões globalmente"""
|
278 |
+
|
279 |
+
EXAMPLE_OUTPUT_1 = """{"death_related": false,"political_related": false,"woke_related": false,"spoilers": false,"sensitive_theme": false,"contains_video": false,"is_news_content": true,"relevance": "medium","brazil_interest": true,"breaking_news": true,"audience_age_rating": 14,"regional_focus": "americas","country_focus": "us","ideological_alignment": "apolitical","entity_type": "series","entity_name": "The Gilded Age","duplication": false}"""
|
280 |
+
|
281 |
+
# Segundo exemplo - REVIEW/CRÍTICA (NÃO É NOTÍCIA)
|
282 |
+
EXAMPLE_INPUT_2 = f"""Title: ‘My Best Friend’s Wedding’ Sequel in the Works: ‘Materialists,’ ‘Past Lives’ Director Celine Song to Write Screenplay
|
283 |
+
Content: A sequel to the Julia Roberts romantic comedy “My Best Friend’s Wedding” is in early development at Sony Pictures. The studio has tapped “Materialists” and “Past Lives” writer-director Celine Song to pen a screenplay for the project, though she is not in talks to helm the feature.
|
284 |
+
Last titles:
|
285 |
+
- Sequência de "The Batman" ganha data de lançamento oficial da Warner Bros
|
286 |
+
- Sequência de "The Batman" de Robert Pattinson tem data oficial de lançamento para 2026
|
287 |
+
- Warner Bros. define data de lançamento da sequência de "The Batman" para 2026
|
288 |
+
- Sequência de 'O Casamento do Meu Melhor Amigo' terá roteiro da diretora de 'Vidas Passadas'"""
|
289 |
+
|
290 |
+
EXAMPLE_OUTPUT_2 = """{"death_related": false,"political_related": false,"woke_related": false,"spoilers": false,"sensitive_theme": false,"contains_video": false,"is_news_content": true,"relevance": "medium","brazil_interest": true,"breaking_news": false,"audience_age_rating": 10,"regional_focus": "americas","country_focus": "us","ideological_alignment": "apolitical","entity_type": "movie","entity_name": "My Best Friend’s Wedding","duplication": true}"""
|
291 |
+
|
292 |
+
# Terceiro exemplo - LISTA/RANKING (NÃO É NOTÍCIA)
|
293 |
+
EXAMPLE_INPUT_3 = f"""Title: 9-1-1: Death of main character shakes series, which gets new date for the 9th season
|
294 |
+
Content: The 9-1-1 universe was permanently redefined after one of the most shocking events in its history. The show's eighth season bid farewell to one of its pillars with the death of Captain Bobby Nash, played by Peter Krause, in episode 15. Now, with the renewal for a ninth season confirmed, ABC has announced a schedule change: the premiere has been moved up to Thursday, October 9, 2025. Bobby Nash's death, the first of a main cast member, leaves a leadership vacuum in Battalion 118 and sets the main narrative arc for the new episodes. Peter Krause's departure had already been signaled, but the impact of his absence will be the driving force behind the next season, which will have 18 episodes. Showrunner Tim Minear had previously stated that, despite the death, the character would still appear in specific moments in the eighth season finale, fulfilling his promise.
|
295 |
+
Last titles:
|
296 |
+
- The Batman 2 ganha data oficial de lançamento para 2026 na Warner Bros
|
297 |
+
- Datas de estreia da ABC no outono de 2025: '9-1-1', 'Nashville' e 'Grey's Anatomy' antecipadas
|
298 |
+
- Warner Bros. anuncia sequência de 'The Batman' para 2026"""
|
299 |
+
|
300 |
+
EXAMPLE_OUTPUT_3 = """{"death_related": false,"political_related": false,"woke_related": false,"spoilers": true,"sensitive_theme": false,"contains_video": false,"is_news_content": true,"relevance": "high","brazil_interest": true,"breaking_news": true,"audience_age_rating": 14,"regional_focus": "global","country_focus": null,"ideological_alignment": "apolitical","entity_type": "series","entity_name": "9-1-1","duplication": true}"""
|
301 |
+
|
302 |
+
# Quarto exemplo - NOTÍCIA REAL DE MORTE
|
303 |
+
EXAMPLE_INPUT_4 = f"""Title: Julian McMahon, 'Fantastic Four,' 'Nip/Tuck' and 'FBI: Most Wanted' Star, Dies at 56
|
304 |
+
Content: Julian McMahon, the suave Australian actor best known for his performances on "FBI: Most Wanted," "Charmed," "Nip/Tuck" and the early aughts "Fantastic Four" films, died Wednesday in Florida. He was 56 and died after a battle with cancer. McMahon's death was confirmed through his reps, who shared a statement from his wife, Kelly McMahon, in remembrance of her husband. "With an open heart, I wish to share with the world that my beloved husband, Julian McMahon, died peacefully this week after a valiant effort to overcome cancer," she said. "Julian loved life. He loved his family. He loved his friends. He loved his work, and he loved his fans. His deepest wish was to bring joy into as many lives as possible. We ask for support during this time to allow our family to grieve in privacy. And we wish for all of those to whom Julian brought joy, to continue to find joy in life. We are grateful for the memories."
|
305 |
+
Last titles:
|
306 |
+
- Mortes de Celebridades em 2025: Estrelas que Perdemos Este Ano
|
307 |
+
- Programas de TV Cancelados em 2025: Quais Séries Foram Canceladas
|
308 |
+
- Atores Australianos que Estão Fazendo Sucesso em Hollywood"""
|
309 |
+
|
310 |
+
EXAMPLE_OUTPUT_4 = """{"death_related": true,"political_related": false,"woke_related": false,"spoilers": false,"sensitive_theme": true,"contains_video": false,"is_news_content": true,"relevance": "medium","brazil_interest": true,"breaking_news": true,"audience_age_rating": 14,"regional_focus": "americas","country_focus": "au","ideological_alignment": "apolitical","entity_type": "person","entity_name": "Julian McMahon","duplication": false}"""
|
311 |
+
|
312 |
+
# Estrutura de conversação correta com múltiplos exemplos
|
313 |
+
contents = [
|
314 |
+
# Primeiro exemplo: usuário envia uma notícia
|
315 |
+
types.Content(
|
316 |
+
role="user",
|
317 |
+
parts=[
|
318 |
+
types.Part.from_text(text=EXAMPLE_INPUT_1)
|
319 |
+
]
|
320 |
+
),
|
321 |
+
# Primeiro exemplo: modelo responde com o formato correto
|
322 |
+
types.Content(
|
323 |
+
role="model",
|
324 |
+
parts=[
|
325 |
+
types.Part.from_text(text=EXAMPLE_OUTPUT_1)
|
326 |
+
]
|
327 |
+
),
|
328 |
+
# Segundo exemplo: usuário envia um review (não é notícia)
|
329 |
+
types.Content(
|
330 |
+
role="user",
|
331 |
+
parts=[
|
332 |
+
types.Part.from_text(text=EXAMPLE_INPUT_2)
|
333 |
+
]
|
334 |
+
),
|
335 |
+
# Segundo exemplo: modelo responde marcando como não-notícia
|
336 |
+
types.Content(
|
337 |
+
role="model",
|
338 |
+
parts=[
|
339 |
+
types.Part.from_text(text=EXAMPLE_OUTPUT_2)
|
340 |
+
]
|
341 |
+
),
|
342 |
+
# Terceiro exemplo: usuário envia uma lista (não é notícia)
|
343 |
+
types.Content(
|
344 |
+
role="user",
|
345 |
+
parts=[
|
346 |
+
types.Part.from_text(text=EXAMPLE_INPUT_3)
|
347 |
+
]
|
348 |
+
),
|
349 |
+
# Terceiro exemplo: modelo responde marcando como não-notícia
|
350 |
+
types.Content(
|
351 |
+
role="model",
|
352 |
+
parts=[
|
353 |
+
types.Part.from_text(text=EXAMPLE_OUTPUT_3)
|
354 |
+
]
|
355 |
+
),
|
356 |
+
# Quarto exemplo: usuário envia notícia real
|
357 |
+
types.Content(
|
358 |
+
role="user",
|
359 |
+
parts=[
|
360 |
+
types.Part.from_text(text=EXAMPLE_INPUT_4)
|
361 |
+
]
|
362 |
+
),
|
363 |
+
# Quarto exemplo: modelo responde marcando como notícia
|
364 |
+
types.Content(
|
365 |
+
role="model",
|
366 |
+
parts=[
|
367 |
+
types.Part.from_text(text=EXAMPLE_OUTPUT_4)
|
368 |
+
]
|
369 |
+
),
|
370 |
+
# Agora o usuário envia a notícia real para ser analisada
|
371 |
+
types.Content(
|
372 |
+
role="user",
|
373 |
+
parts=[
|
374 |
+
types.Part.from_text(text=f"""Title: {title}
|
375 |
+
Content: {content}
|
376 |
+
Last titles:
|
377 |
+
- {last_titles_formatted}""")
|
378 |
+
]
|
379 |
+
)
|
380 |
+
]
|
381 |
+
|
382 |
+
# Ferramentas para pesquisa e pensamento
|
383 |
+
tools = [
|
384 |
+
types.Tool(googleSearch=types.GoogleSearch())
|
385 |
+
]
|
386 |
+
|
387 |
+
config = types.GenerateContentConfig(
|
388 |
+
system_instruction=SYSTEM_INSTRUCTIONS,
|
389 |
+
tools=tools,
|
390 |
+
response_mime_type="text/plain",
|
391 |
+
max_output_tokens=4096,
|
392 |
+
temperature=0.8,
|
393 |
+
)
|
394 |
+
|
395 |
+
response_text = ""
|
396 |
+
for chunk in client.models.generate_content_stream(
|
397 |
+
model=model,
|
398 |
+
contents=contents,
|
399 |
+
config=config
|
400 |
+
):
|
401 |
+
if chunk.text:
|
402 |
+
response_text += chunk.text
|
403 |
+
|
404 |
+
json_result = extract_json(response_text)
|
405 |
+
|
406 |
+
try:
|
407 |
+
parsed = json.loads(json_result)
|
408 |
+
except json.JSONDecodeError as e:
|
409 |
+
raise ValueError("Modelo retornou JSON inválido")
|
410 |
+
|
411 |
+
ALLOWED_KEYS = {
|
412 |
+
"death_related", "political_related", "woke_related", "spoilers",
|
413 |
+
"sensitive_theme", "contains_video", "is_news_content", "relevance",
|
414 |
+
"brazil_interest", "breaking_news", "audience_age_rating", "regional_focus",
|
415 |
+
"country_focus", "ideological_alignment", "entity_type", "entity_name", "duplication"
|
416 |
+
}
|
417 |
+
|
418 |
+
clean_filter = {key: parsed[key] for key in ALLOWED_KEYS if key in parsed}
|
419 |
+
clean_filter = ensure_filter_order(clean_filter)
|
420 |
+
|
421 |
+
return {"filter": clean_filter}
|
422 |
+
|
423 |
+
except Exception as e:
|
424 |
+
raise ValueError(f"Erro na filtragem: {str(e)}")
|
425 |
+
|
426 |
+
def should_skip_insertion(filters: dict) -> tuple[bool, str]:
|
427 |
+
"""
|
428 |
+
Verifica se a notícia deve ser pulada (não inserida na tabela news).
|
429 |
+
Retorna (should_skip, reason)
|
430 |
+
"""
|
431 |
+
|
432 |
+
# Condição 1: Se duplication for true → sempre pular
|
433 |
+
if filters.get("duplication", False):
|
434 |
+
return True, "duplicação detectada"
|
435 |
+
|
436 |
+
# Condição 2: Se is_news_content for false → pular
|
437 |
+
if not filters.get("is_news_content", True):
|
438 |
+
return True, "conteúdo não é notícia (review, lista, crítica, etc.)"
|
439 |
+
|
440 |
+
# Condição 3: Se brazil_interest for false → pular
|
441 |
+
if not filters.get("brazil_interest", True):
|
442 |
+
return True, "baixo interesse para o Brasil (brazil_interest=false)"
|
443 |
+
|
444 |
+
# Condição 4: Se relevance for low ou ausente → pular
|
445 |
+
if filters.get("relevance", "") not in {"medium", "high", "viral"}:
|
446 |
+
return True, f"relevância insuficiente (relevance={filters.get('relevance')})"
|
447 |
+
|
448 |
+
# Se passou por todas, pode inserir
|
449 |
+
return False, ""
|
450 |
+
|
451 |
+
app = FastAPI(title="News Filter API")
|
452 |
+
router = APIRouter()
|
453 |
+
|
454 |
+
@router.post("/filter")
|
455 |
+
async def filter_endpoint():
|
456 |
+
news_data = None
|
457 |
+
news_id = None
|
458 |
+
|
459 |
+
try:
|
460 |
+
# Busca notícia não usada do Supabase
|
461 |
+
news_data = await fetch_unused_news()
|
462 |
+
|
463 |
+
title = news_data.get("title", "")
|
464 |
+
url = news_data.get("url", "")
|
465 |
+
news_id = news_data.get("news_id", "")
|
466 |
+
image_url = news_data.get("image", "")
|
467 |
+
|
468 |
+
if not title.strip() or not url.strip():
|
469 |
+
raise ValueError("Title e URL não podem estar vazios")
|
470 |
+
|
471 |
+
log.info(f"Processando notícia {news_id}: {title}")
|
472 |
+
|
473 |
+
# Busca os últimos 50 títulos
|
474 |
+
last_titles = await fetch_last_50_titles()
|
475 |
+
|
476 |
+
# Extrai texto completo da URL
|
477 |
+
full_text = await extract_article_text(url)
|
478 |
+
|
479 |
+
if not full_text.strip():
|
480 |
+
raise ValueError("Não foi possível extrair texto da URL")
|
481 |
+
|
482 |
+
# Executa análise de filtros com os últimos títulos
|
483 |
+
filter_result = await filter_news(title, full_text, last_titles)
|
484 |
+
|
485 |
+
# Verifica se deve pular a inserção
|
486 |
+
should_skip, skip_reason = should_skip_insertion(filter_result["filter"])
|
487 |
+
|
488 |
+
if should_skip:
|
489 |
+
# Apenas marca como usada, não insere na tabela news
|
490 |
+
await mark_news_as_used(news_id)
|
491 |
+
log.info(f"Notícia {news_id} pulada devido a: {skip_reason}")
|
492 |
+
|
493 |
+
return {
|
494 |
+
"filter": filter_result["filter"],
|
495 |
+
"title_en": title,
|
496 |
+
"text_en": full_text,
|
497 |
+
"news_id": news_id,
|
498 |
+
"url": url,
|
499 |
+
"image": image_url,
|
500 |
+
"last_titles": last_titles,
|
501 |
+
"skipped": True,
|
502 |
+
"skip_reason": skip_reason
|
503 |
+
}
|
504 |
+
else:
|
505 |
+
# Insere na tabela news com filtros
|
506 |
+
await insert_news_to_db(title, full_text, news_id, url, image_url, filter_result["filter"])
|
507 |
+
|
508 |
+
# Marca como usada (sucesso)
|
509 |
+
await mark_news_as_used(news_id)
|
510 |
+
|
511 |
+
log.info(f"Notícia {news_id} processada e inserida com sucesso")
|
512 |
+
|
513 |
+
return {
|
514 |
+
"filter": filter_result["filter"],
|
515 |
+
"title_en": title,
|
516 |
+
"text_en": full_text,
|
517 |
+
"news_id": news_id,
|
518 |
+
"url": url,
|
519 |
+
"image": image_url,
|
520 |
+
"last_titles": last_titles,
|
521 |
+
"skipped": False
|
522 |
+
}
|
523 |
+
|
524 |
+
except Exception as e:
|
525 |
+
error_msg = str(e)
|
526 |
+
log.error(f"Erro no processamento da notícia {news_id}: {error_msg}")
|
527 |
+
|
528 |
+
# SEMPRE marca como usada em caso de erro para evitar loops infinitos
|
529 |
+
if news_id:
|
530 |
+
await mark_news_as_used(news_id)
|
531 |
+
|
532 |
+
# Determina o tipo de erro para o HTTP response
|
533 |
+
if "Nenhuma notícia disponível" in error_msg:
|
534 |
+
raise HTTPException(status_code=404, detail=error_msg)
|
535 |
+
elif "Title e URL não podem estar vazios" in error_msg:
|
536 |
+
raise HTTPException(status_code=400, detail=error_msg)
|
537 |
+
elif "Não foi possível extrair texto" in error_msg:
|
538 |
+
raise HTTPException(status_code=400, detail=error_msg)
|
539 |
+
else:
|
540 |
+
raise HTTPException(status_code=500, detail=f"Erro interno: {error_msg}")
|
541 |
+
|
542 |
+
app.include_router(router)
|
543 |
+
|
544 |
+
@app.on_event("shutdown")
|
545 |
+
async def shutdown_event():
|
546 |
+
global http_session
|
547 |
+
if http_session:
|
548 |
+
await http_session.close()
|
routers/getnews.py
ADDED
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import re
|
3 |
+
import httpx
|
4 |
+
from typing import List, Dict
|
5 |
+
from bs4 import BeautifulSoup
|
6 |
+
from fastapi import APIRouter, HTTPException
|
7 |
+
|
8 |
+
router = APIRouter()
|
9 |
+
|
10 |
+
# 🎯 IMDb GraphQL
|
11 |
+
GRAPHQL_URL = "https://api.graphql.imdb.com"
|
12 |
+
HEADERS = {"Content-Type": "application/json"}
|
13 |
+
|
14 |
+
QUERY = """
|
15 |
+
query GetNews($first: Int!) {
|
16 |
+
movieNews: news(first: $first, category: MOVIE) {
|
17 |
+
edges {
|
18 |
+
node {
|
19 |
+
id
|
20 |
+
articleTitle { plainText }
|
21 |
+
externalUrl
|
22 |
+
date
|
23 |
+
text { plaidHtml }
|
24 |
+
image { url }
|
25 |
+
}
|
26 |
+
}
|
27 |
+
}
|
28 |
+
tvNews: news(first: $first, category: TV) {
|
29 |
+
edges {
|
30 |
+
node {
|
31 |
+
id
|
32 |
+
articleTitle { plainText }
|
33 |
+
externalUrl
|
34 |
+
date
|
35 |
+
text { plaidHtml }
|
36 |
+
image { url }
|
37 |
+
}
|
38 |
+
}
|
39 |
+
}
|
40 |
+
}
|
41 |
+
"""
|
42 |
+
|
43 |
+
# 🔧 Supabase Config
|
44 |
+
SUPABASE_URL = "https://iiwbixdrrhejkthxygak.supabase.co"
|
45 |
+
SUPABASE_KEY = os.getenv("SUPA_KEY")
|
46 |
+
SUPABASE_ROLE_KEY = os.getenv("SUPA_SERVICE_KEY")
|
47 |
+
|
48 |
+
if not SUPABASE_KEY or not SUPABASE_ROLE_KEY:
|
49 |
+
raise ValueError("❌ SUPA_KEY or SUPA_SERVICE_KEY not set in environment!")
|
50 |
+
|
51 |
+
SUPABASE_HEADERS = {
|
52 |
+
"apikey": SUPABASE_KEY,
|
53 |
+
"Authorization": f"Bearer {SUPABASE_KEY}",
|
54 |
+
"Content-Type": "application/json"
|
55 |
+
}
|
56 |
+
|
57 |
+
SUPABASE_ROLE_HEADERS = {
|
58 |
+
"apikey": SUPABASE_ROLE_KEY,
|
59 |
+
"Authorization": f"Bearer {SUPABASE_ROLE_KEY}",
|
60 |
+
"Content-Type": "application/json"
|
61 |
+
}
|
62 |
+
|
63 |
+
# 🧼 HTML Cleanup
|
64 |
+
def clean_html(raw_html: str) -> str:
|
65 |
+
text = BeautifulSoup(raw_html or "", "html.parser").get_text(separator=" ", strip=True)
|
66 |
+
text = re.sub(r"\s+", " ", text)
|
67 |
+
text = re.sub(r"\s+([.,;:!?])", r"\1", text)
|
68 |
+
text = re.sub(r"\(\s+", "(", text)
|
69 |
+
text = re.sub(r"\s+\)", ")", text)
|
70 |
+
text = re.sub(r"\[\s+", "[", text)
|
71 |
+
text = re.sub(r"\s+\]", "]", text)
|
72 |
+
text = re.sub(r"\{\s+", "{", text)
|
73 |
+
text = re.sub(r"\s+\}", "}", text)
|
74 |
+
return text.strip()
|
75 |
+
|
76 |
+
# 🚀 Endpoint principal
|
77 |
+
@router.get("/news")
|
78 |
+
async def get_news(first: int = 20) -> List[Dict]:
|
79 |
+
payload = {
|
80 |
+
"query": QUERY,
|
81 |
+
"variables": {"first": first}
|
82 |
+
}
|
83 |
+
|
84 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
85 |
+
# Pega notícias do IMDb
|
86 |
+
response = await client.post(GRAPHQL_URL, headers=HEADERS, json=payload)
|
87 |
+
|
88 |
+
if response.status_code != 200:
|
89 |
+
raise HTTPException(status_code=502, detail="Erro ao acessar a API do IMDb")
|
90 |
+
|
91 |
+
data = response.json().get("data")
|
92 |
+
if not data:
|
93 |
+
raise HTTPException(status_code=500, detail="Resposta inválida da API")
|
94 |
+
|
95 |
+
combined = []
|
96 |
+
|
97 |
+
for category_key in ["movieNews", "tvNews"]:
|
98 |
+
for edge in data.get(category_key, {}).get("edges", []):
|
99 |
+
node = edge.get("node", {})
|
100 |
+
image_data = node.get("image")
|
101 |
+
combined.append({
|
102 |
+
"news_id": node.get("id"),
|
103 |
+
"title": node.get("articleTitle", {}).get("plainText"),
|
104 |
+
"url": node.get("externalUrl"),
|
105 |
+
"date": node.get("date"),
|
106 |
+
"text": clean_html(node.get("text", {}).get("plaidHtml")),
|
107 |
+
"image": image_data.get("url") if image_data else None,
|
108 |
+
"category": category_key.replace("News", "").upper()
|
109 |
+
})
|
110 |
+
|
111 |
+
# 📌 Verifica quais IDs já existem no Supabase
|
112 |
+
all_ids = [item["news_id"] for item in combined]
|
113 |
+
|
114 |
+
existing_ids = []
|
115 |
+
ids_chunks = [all_ids[i:i + 1000] for i in range(0, len(all_ids), 1000)] # evita URL muito grande
|
116 |
+
|
117 |
+
for chunk in ids_chunks:
|
118 |
+
query_ids = ",".join([f"\"{nid}\"" for nid in chunk])
|
119 |
+
url = f"{SUPABASE_URL}/rest/v1/news_extraction?select=news_id&news_id=in.({query_ids})"
|
120 |
+
r = await client.get(url, headers=SUPABASE_HEADERS)
|
121 |
+
if r.status_code == 200:
|
122 |
+
existing_ids.extend([item["news_id"] for item in r.json()])
|
123 |
+
|
124 |
+
# 🔎 Filtra apenas as novas notícias
|
125 |
+
new_entries = [item for item in combined if item["news_id"] not in existing_ids]
|
126 |
+
|
127 |
+
# 🧾 Insere novas notícias (em lote)
|
128 |
+
if new_entries:
|
129 |
+
insert_url = f"{SUPABASE_URL}/rest/v1/news_extraction"
|
130 |
+
await client.post(insert_url, headers=SUPABASE_ROLE_HEADERS, json=new_entries)
|
131 |
+
|
132 |
+
# 🔃 Ordena por data
|
133 |
+
combined.sort(key=lambda x: x.get("date"), reverse=True)
|
134 |
+
return combined
|
routers/image.py
ADDED
@@ -0,0 +1,573 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Query, HTTPException
|
2 |
+
from fastapi.responses import StreamingResponse
|
3 |
+
from PIL import Image, ImageDraw, ImageFont
|
4 |
+
from io import BytesIO
|
5 |
+
import requests
|
6 |
+
import re
|
7 |
+
from typing import Optional, List, Tuple
|
8 |
+
|
9 |
+
router = APIRouter()
|
10 |
+
|
11 |
+
def download_image_from_url(url: str) -> Image.Image:
|
12 |
+
headers = {
|
13 |
+
"User-Agent": (
|
14 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
15 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
16 |
+
"Chrome/115.0.0.0 Safari/537.36"
|
17 |
+
)
|
18 |
+
}
|
19 |
+
try:
|
20 |
+
response = requests.get(url, headers=headers, timeout=10)
|
21 |
+
response.raise_for_status()
|
22 |
+
return Image.open(BytesIO(response.content)).convert("RGBA")
|
23 |
+
except Exception as e:
|
24 |
+
raise HTTPException(status_code=400, detail=f"Erro ao baixar imagem: {url} ({str(e)})")
|
25 |
+
|
26 |
+
def resize_and_crop_to_fill(img: Image.Image, target_width: int, target_height: int) -> Image.Image:
|
27 |
+
img_ratio = img.width / img.height
|
28 |
+
target_ratio = target_width / target_height
|
29 |
+
|
30 |
+
if img_ratio > target_ratio:
|
31 |
+
scale_height = target_height
|
32 |
+
scale_width = int(scale_height * img_ratio)
|
33 |
+
else:
|
34 |
+
scale_width = target_width
|
35 |
+
scale_height = int(scale_width / img_ratio)
|
36 |
+
|
37 |
+
img_resized = img.resize((scale_width, scale_height), Image.LANCZOS)
|
38 |
+
left = (scale_width - target_width) // 2
|
39 |
+
top = (scale_height - target_height) // 2
|
40 |
+
return img_resized.crop((left, top, left + target_width, top + target_height))
|
41 |
+
|
42 |
+
def create_gradient_overlay(width: int, height: int, positions: List[str], expanded: bool = False) -> Image.Image:
|
43 |
+
gradient = Image.new("RGBA", (width, height))
|
44 |
+
draw = ImageDraw.Draw(gradient)
|
45 |
+
|
46 |
+
for position in positions:
|
47 |
+
if position.lower() == "bottom":
|
48 |
+
if expanded:
|
49 |
+
# Gradiente expandido para quando há texto + citação
|
50 |
+
gradient_start = 500 # Começar mais alto
|
51 |
+
gradient_height = 850 # Mais altura para cobrir ambos
|
52 |
+
else:
|
53 |
+
# Gradiente normal
|
54 |
+
gradient_start = 650 # Começar mais baixo para ser mais sutil
|
55 |
+
gradient_height = 700 # Altura menor para gradiente mais localizado
|
56 |
+
|
57 |
+
for y in range(gradient_height):
|
58 |
+
if y + gradient_start < height:
|
59 |
+
ratio = y / gradient_height
|
60 |
+
# Gradiente muito mais suave com opacidades menores
|
61 |
+
if ratio <= 0.3:
|
62 |
+
opacity = int(255 * 0.15 * (ratio / 0.3)) # Começar muito sutil
|
63 |
+
elif ratio <= 0.6:
|
64 |
+
opacity = int(255 * (0.15 + 0.20 * ((ratio - 0.3) / 0.3))) # Crescimento gradual
|
65 |
+
else:
|
66 |
+
opacity = int(255 * (0.35 + 0.15 * ((ratio - 0.6) / 0.4))) # Max de 50% de opacidade
|
67 |
+
|
68 |
+
current_pixel = gradient.getpixel((0, y + gradient_start))
|
69 |
+
combined_opacity = min(255, current_pixel[3] + opacity)
|
70 |
+
draw.line([(0, y + gradient_start), (width, y + gradient_start)], fill=(0, 0, 0, combined_opacity))
|
71 |
+
|
72 |
+
elif position.lower() == "top":
|
73 |
+
if expanded:
|
74 |
+
# Gradiente expandido para quando há texto + citação
|
75 |
+
gradient_height = 850 # Maior altura para cobrir ambos
|
76 |
+
else:
|
77 |
+
# Gradiente normal
|
78 |
+
gradient_height = 650 # Menor altura para ser mais sutil
|
79 |
+
|
80 |
+
for y in range(gradient_height):
|
81 |
+
if y < height:
|
82 |
+
ratio = (gradient_height - y) / gradient_height
|
83 |
+
# Opacidades muito menores para efeito mais sutil
|
84 |
+
if ratio <= 0.2:
|
85 |
+
opacity = int(255 * 0.12 * (ratio / 0.2)) # Muito sutil no início
|
86 |
+
elif ratio <= 0.5:
|
87 |
+
opacity = int(255 * (0.12 + 0.18 * ((ratio - 0.2) / 0.3))) # Crescimento suave
|
88 |
+
else:
|
89 |
+
opacity = int(255 * (0.30 + 0.15 * ((ratio - 0.5) / 0.5))) # Max de 45%
|
90 |
+
|
91 |
+
current_pixel = gradient.getpixel((0, y))
|
92 |
+
combined_opacity = min(255, current_pixel[3] + opacity)
|
93 |
+
draw.line([(0, y), (width, y)], fill=(0, 0, 0, combined_opacity))
|
94 |
+
|
95 |
+
return gradient
|
96 |
+
|
97 |
+
class TextSegment:
|
98 |
+
def __init__(self, text: str, is_bold: bool = False, is_italic: bool = False):
|
99 |
+
self.text = text
|
100 |
+
self.is_bold = is_bold
|
101 |
+
self.is_italic = is_italic
|
102 |
+
|
103 |
+
def parse_text_with_formatting(text: str) -> List[TextSegment]:
|
104 |
+
pattern = r'(<(?:strong|em)>.*?</(?:strong|em)>|<(?:strong|em)><(?:strong|em)>.*?</(?:strong|em)></(?:strong|em)>)'
|
105 |
+
parts = re.split(pattern, text)
|
106 |
+
segments = []
|
107 |
+
|
108 |
+
for part in parts:
|
109 |
+
if not part:
|
110 |
+
continue
|
111 |
+
|
112 |
+
if match := re.match(r'<strong><em>(.*?)</em></strong>|<em><strong>(.*?)</strong></em>', part):
|
113 |
+
content = match.group(1) or match.group(2)
|
114 |
+
segments.append(TextSegment(content, True, True))
|
115 |
+
elif match := re.match(r'<strong>(.*?)</strong>', part):
|
116 |
+
segments.append(TextSegment(match.group(1), True, False))
|
117 |
+
elif match := re.match(r'<em>(.*?)</em>', part):
|
118 |
+
segments.append(TextSegment(match.group(1), False, True))
|
119 |
+
else:
|
120 |
+
segments.append(TextSegment(part, False, False))
|
121 |
+
|
122 |
+
return segments
|
123 |
+
|
124 |
+
def get_font(segment: TextSegment, regular_font: ImageFont.FreeTypeFont,
|
125 |
+
bold_font: ImageFont.FreeTypeFont, italic_font: ImageFont.FreeTypeFont,
|
126 |
+
bold_italic_font: ImageFont.FreeTypeFont) -> ImageFont.FreeTypeFont:
|
127 |
+
if segment.is_bold and segment.is_italic:
|
128 |
+
return bold_italic_font
|
129 |
+
elif segment.is_bold:
|
130 |
+
return bold_font
|
131 |
+
elif segment.is_italic:
|
132 |
+
return italic_font
|
133 |
+
return regular_font
|
134 |
+
|
135 |
+
def wrap_text_with_formatting(segments: List[TextSegment], regular_font: ImageFont.FreeTypeFont,
|
136 |
+
bold_font: ImageFont.FreeTypeFont, italic_font: ImageFont.FreeTypeFont,
|
137 |
+
bold_italic_font: ImageFont.FreeTypeFont, max_width: int,
|
138 |
+
draw: ImageDraw.Draw) -> List[List[TextSegment]]:
|
139 |
+
lines = []
|
140 |
+
current_line = []
|
141 |
+
current_width = 0
|
142 |
+
first_in_line = True
|
143 |
+
|
144 |
+
for segment in segments:
|
145 |
+
if not segment.text:
|
146 |
+
continue
|
147 |
+
|
148 |
+
font = get_font(segment, regular_font, bold_font, italic_font, bold_italic_font)
|
149 |
+
|
150 |
+
words = re.split(r'(\s+)', segment.text)
|
151 |
+
|
152 |
+
for word in words:
|
153 |
+
if not word:
|
154 |
+
continue
|
155 |
+
|
156 |
+
if re.match(r'\s+', word):
|
157 |
+
if not first_in_line:
|
158 |
+
current_line.append(TextSegment(word, segment.is_bold, segment.is_italic))
|
159 |
+
current_width += draw.textlength(word, font=font)
|
160 |
+
continue
|
161 |
+
|
162 |
+
word_width = draw.textlength(word, font=font)
|
163 |
+
|
164 |
+
if current_width + word_width <= max_width or first_in_line:
|
165 |
+
current_line.append(TextSegment(word, segment.is_bold, segment.is_italic))
|
166 |
+
current_width += word_width
|
167 |
+
first_in_line = False
|
168 |
+
else:
|
169 |
+
if current_line:
|
170 |
+
lines.append(current_line)
|
171 |
+
current_line = [TextSegment(word, segment.is_bold, segment.is_italic)]
|
172 |
+
current_width = word_width
|
173 |
+
first_in_line = False
|
174 |
+
|
175 |
+
if current_line:
|
176 |
+
lines.append(current_line)
|
177 |
+
|
178 |
+
return lines
|
179 |
+
|
180 |
+
def wrap_simple_text(text: str, font: ImageFont.FreeTypeFont, max_width: int,
|
181 |
+
draw: ImageDraw.Draw) -> List[str]:
|
182 |
+
words = text.split()
|
183 |
+
lines = []
|
184 |
+
current_line = []
|
185 |
+
|
186 |
+
for word in words:
|
187 |
+
test_line = ' '.join(current_line + [word])
|
188 |
+
if draw.textlength(test_line, font=font) <= max_width or not current_line:
|
189 |
+
current_line.append(word)
|
190 |
+
else:
|
191 |
+
if current_line:
|
192 |
+
lines.append(' '.join(current_line))
|
193 |
+
current_line = [word]
|
194 |
+
|
195 |
+
if current_line:
|
196 |
+
lines.append(' '.join(current_line))
|
197 |
+
|
198 |
+
return lines
|
199 |
+
|
200 |
+
def get_responsive_fonts(text: str, regular_font_path: str, bold_font_path: str,
|
201 |
+
italic_font_path: str, bold_italic_font_path: str,
|
202 |
+
max_width: int, max_lines: int, max_font_size: int, min_font_size: int,
|
203 |
+
draw: ImageDraw.Draw) -> Tuple[ImageFont.FreeTypeFont, ImageFont.FreeTypeFont,
|
204 |
+
ImageFont.FreeTypeFont, ImageFont.FreeTypeFont, List[List[TextSegment]], int]:
|
205 |
+
segments = parse_text_with_formatting(text)
|
206 |
+
current_font_size = max_font_size
|
207 |
+
|
208 |
+
while current_font_size >= min_font_size:
|
209 |
+
try:
|
210 |
+
fonts = [ImageFont.truetype(path, current_font_size)
|
211 |
+
for path in [regular_font_path, bold_font_path, italic_font_path, bold_italic_font_path]]
|
212 |
+
regular_font, bold_font, italic_font, bold_italic_font = fonts
|
213 |
+
except Exception:
|
214 |
+
fonts = [ImageFont.load_default()] * 4
|
215 |
+
regular_font, bold_font, italic_font, bold_italic_font = fonts
|
216 |
+
|
217 |
+
lines = wrap_text_with_formatting(segments, regular_font, bold_font, italic_font, bold_italic_font, max_width, draw)
|
218 |
+
|
219 |
+
if len(lines) <= max_lines:
|
220 |
+
return regular_font, bold_font, italic_font, bold_italic_font, lines, current_font_size
|
221 |
+
|
222 |
+
current_font_size -= 1
|
223 |
+
|
224 |
+
return regular_font, bold_font, italic_font, bold_italic_font, lines, min_font_size
|
225 |
+
|
226 |
+
def get_responsive_single_font(text: str, font_path: str, max_width: int, max_lines: int,
|
227 |
+
max_font_size: int, min_font_size: int, draw: ImageDraw.Draw,
|
228 |
+
format_text: bool = False) -> Tuple[ImageFont.FreeTypeFont, List[str], int]:
|
229 |
+
formatted_text = f"{text}" if format_text else text
|
230 |
+
current_font_size = max_font_size
|
231 |
+
|
232 |
+
while current_font_size >= min_font_size:
|
233 |
+
try:
|
234 |
+
font = ImageFont.truetype(font_path, current_font_size)
|
235 |
+
except Exception:
|
236 |
+
font = ImageFont.load_default()
|
237 |
+
|
238 |
+
lines = wrap_simple_text(formatted_text, font, max_width, draw)
|
239 |
+
|
240 |
+
if len(lines) <= max_lines:
|
241 |
+
return font, lines, current_font_size
|
242 |
+
|
243 |
+
current_font_size -= 2
|
244 |
+
|
245 |
+
return font, lines, min_font_size
|
246 |
+
|
247 |
+
def draw_formatted_line(draw: ImageDraw.Draw, line_segments: List[TextSegment],
|
248 |
+
x: int, y: int, regular_font: ImageFont.FreeTypeFont,
|
249 |
+
bold_font: ImageFont.FreeTypeFont, italic_font: ImageFont.FreeTypeFont,
|
250 |
+
bold_italic_font: ImageFont.FreeTypeFont, color: tuple = (255, 255, 255)):
|
251 |
+
current_x = x
|
252 |
+
|
253 |
+
for segment in line_segments:
|
254 |
+
font = get_font(segment, regular_font, bold_font, italic_font, bold_italic_font)
|
255 |
+
draw.text((current_x, y), segment.text, font=font, fill=color)
|
256 |
+
current_x += draw.textlength(segment.text, font=font)
|
257 |
+
|
258 |
+
def get_text_color_rgb(text_color: str) -> tuple[int, int, int]:
|
259 |
+
"""
|
260 |
+
Converte o parâmetro text_color para RGB.
|
261 |
+
"""
|
262 |
+
if text_color.lower() == "black":
|
263 |
+
return (0, 0, 0)
|
264 |
+
else: # white por padrão
|
265 |
+
return (255, 255, 255)
|
266 |
+
|
267 |
+
def get_device_dimensions() -> tuple[int, int]:
|
268 |
+
return (1080, 1350)
|
269 |
+
|
270 |
+
def add_logo(canvas: Image.Image):
|
271 |
+
try:
|
272 |
+
logo = Image.open("recurve.png").convert("RGBA")
|
273 |
+
logo_resized = logo.resize((121, 23))
|
274 |
+
|
275 |
+
logo_with_opacity = Image.new("RGBA", logo_resized.size)
|
276 |
+
for x in range(logo_resized.width):
|
277 |
+
for y in range(logo_resized.height):
|
278 |
+
r, g, b, a = logo_resized.getpixel((x, y))
|
279 |
+
logo_with_opacity.putpixel((x, y), (r, g, b, int(a * 0.42)))
|
280 |
+
|
281 |
+
canvas.paste(logo_with_opacity, (891, 1274), logo_with_opacity)
|
282 |
+
except Exception as e:
|
283 |
+
print(f"Aviso: Erro ao carregar a logo: {e}")
|
284 |
+
|
285 |
+
def create_canvas(image_url: Optional[str], text: Optional[str], text_position: str = "bottom",
|
286 |
+
citation: Optional[str] = None, citation_direction: str = "bottom",
|
287 |
+
text_color: str = "white") -> BytesIO:
|
288 |
+
width, height = get_device_dimensions()
|
289 |
+
padding_x, top_padding, citation_text_gap = 60, 60, 15
|
290 |
+
max_width = width - 2 * padding_x
|
291 |
+
text_rgb = get_text_color_rgb(text_color)
|
292 |
+
|
293 |
+
# Validação das combinações de posições
|
294 |
+
if text:
|
295 |
+
valid_combinations = {
|
296 |
+
"top": ["text-bottom", "text-top", "bottom"],
|
297 |
+
"bottom": ["text-top", "text-bottom", "top"]
|
298 |
+
}
|
299 |
+
|
300 |
+
if citation and citation_direction not in valid_combinations.get(text_position, []):
|
301 |
+
raise HTTPException(
|
302 |
+
status_code=400,
|
303 |
+
detail=f"Combinação inválida: text_position='{text_position}' com citation_direction='{citation_direction}'. "
|
304 |
+
f"Para text_position='{text_position}', use citation_direction em: {valid_combinations.get(text_position, [])}"
|
305 |
+
)
|
306 |
+
|
307 |
+
canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255))
|
308 |
+
|
309 |
+
if image_url:
|
310 |
+
img = download_image_from_url(image_url)
|
311 |
+
filled_img = resize_and_crop_to_fill(img, width, height)
|
312 |
+
canvas.paste(filled_img, (0, 0))
|
313 |
+
|
314 |
+
# Determinar posições do gradiente e se precisa expandir
|
315 |
+
# Só aplicar gradiente se o texto não for preto
|
316 |
+
gradient_positions = []
|
317 |
+
needs_expanded_gradient = False
|
318 |
+
|
319 |
+
if text_color.lower() != "black":
|
320 |
+
if text and text_position.lower() == "bottom":
|
321 |
+
gradient_positions.append("bottom")
|
322 |
+
# Se há citação com text-top, expande o gradiente bottom
|
323 |
+
if citation and citation_direction.lower() == "text-top":
|
324 |
+
needs_expanded_gradient = True
|
325 |
+
elif text and text_position.lower() == "top":
|
326 |
+
gradient_positions.append("top")
|
327 |
+
# Se há citação com text-bottom, expande o gradiente top
|
328 |
+
if citation and citation_direction.lower() == "text-bottom":
|
329 |
+
needs_expanded_gradient = True
|
330 |
+
|
331 |
+
# Adicionar gradientes para citações em posições fixas
|
332 |
+
if citation and citation_direction.lower() == "top" and "top" not in gradient_positions:
|
333 |
+
gradient_positions.append("top")
|
334 |
+
elif citation and citation_direction.lower() == "bottom" and "bottom" not in gradient_positions:
|
335 |
+
gradient_positions.append("bottom")
|
336 |
+
|
337 |
+
if gradient_positions:
|
338 |
+
gradient_overlay = create_gradient_overlay(width, height, gradient_positions, needs_expanded_gradient)
|
339 |
+
canvas = Image.alpha_composite(canvas, gradient_overlay)
|
340 |
+
|
341 |
+
add_logo(canvas)
|
342 |
+
|
343 |
+
if text or citation:
|
344 |
+
canvas_rgb = canvas.convert("RGB")
|
345 |
+
draw = ImageDraw.Draw(canvas_rgb)
|
346 |
+
|
347 |
+
font_paths = {
|
348 |
+
'regular': "fonts/WorkSans-Regular.ttf",
|
349 |
+
'bold': "fonts/WorkSans-SemiBold.ttf",
|
350 |
+
'italic': "fonts/WorkSans-Italic.ttf",
|
351 |
+
'bold_italic': "fonts/WorkSans-SemiBoldItalic.ttf",
|
352 |
+
'citation': "fonts/AGaramondPro-Semibold.ttf"
|
353 |
+
}
|
354 |
+
|
355 |
+
text_lines, text_height, citation_lines, citation_height = [], 0, [], 0
|
356 |
+
|
357 |
+
if text:
|
358 |
+
try:
|
359 |
+
regular_font, bold_font, italic_font, bold_italic_font, text_lines, font_size = get_responsive_fonts(
|
360 |
+
text, font_paths['regular'], font_paths['bold'], font_paths['italic'],
|
361 |
+
font_paths['bold_italic'], max_width, 5, 35, 15, draw
|
362 |
+
)
|
363 |
+
text_height = len(text_lines) * int(font_size * 1.2)
|
364 |
+
except Exception:
|
365 |
+
text_lines = [[TextSegment(word, False, False) for word in text.split()]]
|
366 |
+
text_height = 40
|
367 |
+
|
368 |
+
if citation:
|
369 |
+
try:
|
370 |
+
citation_font, citation_lines, citation_font_size = get_responsive_single_font(
|
371 |
+
citation, font_paths['citation'], max_width, 3, 60, 30, draw, True
|
372 |
+
)
|
373 |
+
citation_height = len(citation_lines) * int(citation_font_size * 1.05)
|
374 |
+
except Exception:
|
375 |
+
citation_lines = [citation]
|
376 |
+
citation_height = 40
|
377 |
+
|
378 |
+
# Calcular posições respeitando os limites da imagem
|
379 |
+
text_y = citation_y = 0
|
380 |
+
bottom_limit = 1274 - 50 # Limite inferior (antes da logo)
|
381 |
+
|
382 |
+
if text and citation:
|
383 |
+
# Calcular espaço total necessário quando há texto e citação
|
384 |
+
total_gap = citation_text_gap if citation_direction in ["text-top", "text-bottom"] else 0
|
385 |
+
total_content_height = text_height + citation_height + total_gap
|
386 |
+
|
387 |
+
if citation_direction.lower() == "text-top":
|
388 |
+
# Citação acima do texto
|
389 |
+
if text_position.lower() == "bottom":
|
390 |
+
# Posicionar do bottom para cima
|
391 |
+
text_y = min(bottom_limit - text_height, bottom_limit - text_height)
|
392 |
+
citation_y = text_y - citation_text_gap - citation_height
|
393 |
+
# Verificar se vaza pelo topo
|
394 |
+
if citation_y < top_padding:
|
395 |
+
# Reajustar para caber tudo
|
396 |
+
available_height = bottom_limit - top_padding
|
397 |
+
if total_content_height <= available_height:
|
398 |
+
citation_y = top_padding
|
399 |
+
text_y = citation_y + citation_height + citation_text_gap
|
400 |
+
else: # text top
|
401 |
+
# Posicionar do top para baixo
|
402 |
+
citation_y = top_padding
|
403 |
+
text_y = citation_y + citation_height + citation_text_gap
|
404 |
+
# Verificar se vaza pelo bottom
|
405 |
+
if text_y + text_height > bottom_limit:
|
406 |
+
# Reajustar para caber tudo
|
407 |
+
available_height = bottom_limit - top_padding
|
408 |
+
if total_content_height <= available_height:
|
409 |
+
text_y = bottom_limit - text_height
|
410 |
+
citation_y = text_y - citation_text_gap - citation_height
|
411 |
+
|
412 |
+
elif citation_direction.lower() == "text-bottom":
|
413 |
+
# Citação abaixo do texto
|
414 |
+
if text_position.lower() == "bottom":
|
415 |
+
# Posicionar do bottom para cima
|
416 |
+
citation_y = bottom_limit - citation_height
|
417 |
+
text_y = citation_y - citation_text_gap - text_height
|
418 |
+
# Verificar se vaza pelo topo
|
419 |
+
if text_y < top_padding:
|
420 |
+
# Reajustar para caber tudo
|
421 |
+
available_height = bottom_limit - top_padding
|
422 |
+
if total_content_height <= available_height:
|
423 |
+
text_y = top_padding
|
424 |
+
citation_y = text_y + text_height + citation_text_gap
|
425 |
+
else: # text top
|
426 |
+
# Posicionar do top para baixo
|
427 |
+
text_y = top_padding
|
428 |
+
citation_y = text_y + text_height + citation_text_gap
|
429 |
+
# Verificar se vaza pelo bottom
|
430 |
+
if citation_y + citation_height > bottom_limit:
|
431 |
+
# Reajustar para caber tudo
|
432 |
+
available_height = bottom_limit - top_padding
|
433 |
+
if total_content_height <= available_height:
|
434 |
+
citation_y = bottom_limit - citation_height
|
435 |
+
text_y = citation_y - citation_text_gap - text_height
|
436 |
+
|
437 |
+
elif citation_direction.lower() == "top":
|
438 |
+
# Citação no topo, texto na posição original
|
439 |
+
citation_y = top_padding
|
440 |
+
if text_position.lower() == "bottom":
|
441 |
+
text_y = bottom_limit - text_height
|
442 |
+
else:
|
443 |
+
# Evitar sobreposição
|
444 |
+
text_y = max(top_padding + citation_height + citation_text_gap, top_padding)
|
445 |
+
|
446 |
+
elif citation_direction.lower() == "bottom":
|
447 |
+
# Citação no bottom, texto na posição original
|
448 |
+
citation_y = bottom_limit - citation_height
|
449 |
+
if text_position.lower() == "top":
|
450 |
+
text_y = top_padding
|
451 |
+
else:
|
452 |
+
# Evitar sobreposição
|
453 |
+
text_y = min(bottom_limit - text_height, citation_y - citation_text_gap - text_height)
|
454 |
+
|
455 |
+
elif text:
|
456 |
+
# Apenas texto, posições fixas originais
|
457 |
+
if text_position.lower() == "bottom":
|
458 |
+
text_y = bottom_limit - text_height
|
459 |
+
else:
|
460 |
+
text_y = top_padding
|
461 |
+
|
462 |
+
elif citation:
|
463 |
+
# Apenas citação
|
464 |
+
if citation_direction.lower() == "top":
|
465 |
+
citation_y = top_padding
|
466 |
+
elif citation_direction.lower() == "bottom":
|
467 |
+
citation_y = bottom_limit - citation_height
|
468 |
+
|
469 |
+
# Desenhar citação
|
470 |
+
if citation_lines:
|
471 |
+
line_height = int(citation_font_size * 1.05) if 'citation_font_size' in locals() else 40
|
472 |
+
for i, line in enumerate(citation_lines):
|
473 |
+
draw.text((padding_x, citation_y + i * line_height), line,
|
474 |
+
font=citation_font if 'citation_font' in locals() else ImageFont.load_default(),
|
475 |
+
fill=text_rgb)
|
476 |
+
|
477 |
+
# Desenhar texto
|
478 |
+
if text_lines:
|
479 |
+
line_height = int(font_size * 1.2) if 'font_size' in locals() else 40
|
480 |
+
for i, line in enumerate(text_lines):
|
481 |
+
if 'regular_font' in locals():
|
482 |
+
draw_formatted_line(draw, line, padding_x, text_y + i * line_height,
|
483 |
+
regular_font, bold_font, italic_font, bold_italic_font, text_rgb)
|
484 |
+
else:
|
485 |
+
draw.text((padding_x, text_y + i * line_height), ' '.join([s.text for s in line]),
|
486 |
+
font=ImageFont.load_default(), fill=text_rgb)
|
487 |
+
|
488 |
+
canvas = canvas_rgb.convert("RGBA")
|
489 |
+
|
490 |
+
buffer = BytesIO()
|
491 |
+
canvas.convert("RGB").save(buffer, format="PNG")
|
492 |
+
buffer.seek(0)
|
493 |
+
return buffer
|
494 |
+
|
495 |
+
def create_cover_canvas(image_url: Optional[str], title: Optional[str], title_position: str = "bottom",
|
496 |
+
text_color: str = "white") -> BytesIO:
|
497 |
+
width, height = get_device_dimensions()
|
498 |
+
padding_x, top_padding = 60, 60
|
499 |
+
max_width = width - 2 * padding_x
|
500 |
+
text_rgb = get_text_color_rgb(text_color)
|
501 |
+
|
502 |
+
canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255))
|
503 |
+
|
504 |
+
if image_url:
|
505 |
+
img = download_image_from_url(image_url)
|
506 |
+
filled_img = resize_and_crop_to_fill(img, width, height)
|
507 |
+
canvas.paste(filled_img, (0, 0))
|
508 |
+
|
509 |
+
gradient_positions = []
|
510 |
+
# Só aplicar gradiente se o texto não for preto
|
511 |
+
if title and text_color.lower() != "black":
|
512 |
+
gradient_positions.append(title_position.lower())
|
513 |
+
|
514 |
+
if gradient_positions:
|
515 |
+
gradient_overlay = create_gradient_overlay(width, height, gradient_positions)
|
516 |
+
canvas = Image.alpha_composite(canvas, gradient_overlay)
|
517 |
+
|
518 |
+
add_logo(canvas)
|
519 |
+
|
520 |
+
if title:
|
521 |
+
canvas_rgb = canvas.convert("RGB")
|
522 |
+
draw = ImageDraw.Draw(canvas_rgb)
|
523 |
+
|
524 |
+
try:
|
525 |
+
title_font, title_lines, title_font_size = get_responsive_single_font(
|
526 |
+
title, "fonts/AGaramondPro-Regular.ttf", max_width, 3, 85, 40, draw
|
527 |
+
)
|
528 |
+
title_line_height = int(title_font_size * 1.2)
|
529 |
+
title_height = len(title_lines) * title_line_height
|
530 |
+
except Exception:
|
531 |
+
title_font = ImageFont.load_default()
|
532 |
+
title_lines = [title]
|
533 |
+
title_line_height = title_height = 50
|
534 |
+
|
535 |
+
title_y = (1274 - 50 - title_height) if title_position.lower() == "bottom" else top_padding
|
536 |
+
|
537 |
+
for i, line in enumerate(title_lines):
|
538 |
+
draw.text((padding_x, title_y + i * title_line_height), line, font=title_font, fill=text_rgb)
|
539 |
+
|
540 |
+
canvas = canvas_rgb.convert("RGBA")
|
541 |
+
|
542 |
+
buffer = BytesIO()
|
543 |
+
canvas.convert("RGB").save(buffer, format="PNG")
|
544 |
+
buffer.seek(0)
|
545 |
+
return buffer
|
546 |
+
|
547 |
+
@router.get("/create/image")
|
548 |
+
def get_news_image(
|
549 |
+
image_url: Optional[str] = Query(None, description="URL da imagem de fundo"),
|
550 |
+
text: Optional[str] = Query(None, description="Texto com suporte a tags <strong>"),
|
551 |
+
text_position: str = Query("bottom", description="Posição do texto: 'top' para topo ou 'bottom' para parte inferior"),
|
552 |
+
citation: Optional[str] = Query(None, description="Texto da citação"),
|
553 |
+
citation_direction: str = Query("bottom", description="Posição da citação: 'top', 'bottom' ou 'text-top'"),
|
554 |
+
text_color: str = Query("white", description="Cor do texto: 'white' (padrão) ou 'black'. Se 'black', remove o gradiente de fundo")
|
555 |
+
):
|
556 |
+
try:
|
557 |
+
buffer = create_canvas(image_url, text, text_position, citation, citation_direction, text_color)
|
558 |
+
return StreamingResponse(buffer, media_type="image/png")
|
559 |
+
except Exception as e:
|
560 |
+
raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}")
|
561 |
+
|
562 |
+
@router.get("/create/cover/image")
|
563 |
+
def get_cover_image(
|
564 |
+
image_url: Optional[str] = Query(None, description="URL da imagem de fundo"),
|
565 |
+
title: Optional[str] = Query(None, description="Título da capa"),
|
566 |
+
title_position: str = Query("bottom", description="Posição do título: 'top' para topo ou 'bottom' para parte inferior"),
|
567 |
+
text_color: str = Query("white", description="Cor do texto: 'white' (padrão) ou 'black'. Se 'black', remove o gradiente de fundo")
|
568 |
+
):
|
569 |
+
try:
|
570 |
+
buffer = create_cover_canvas(image_url, title, title_position, text_color)
|
571 |
+
return StreamingResponse(buffer, media_type="image/png")
|
572 |
+
except Exception as e:
|
573 |
+
raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}")
|
routers/inference.py
ADDED
@@ -0,0 +1,566 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
import json
|
4 |
+
from fastapi import APIRouter, HTTPException
|
5 |
+
from pydantic import BaseModel
|
6 |
+
from google import genai
|
7 |
+
from google.genai import types
|
8 |
+
from datetime import datetime
|
9 |
+
from zoneinfo import ZoneInfo
|
10 |
+
import locale
|
11 |
+
import re
|
12 |
+
|
13 |
+
# Configurar logging
|
14 |
+
logger = logging.getLogger(__name__)
|
15 |
+
|
16 |
+
router = APIRouter()
|
17 |
+
|
18 |
+
class NewsRequest(BaseModel):
|
19 |
+
content: str
|
20 |
+
|
21 |
+
class NewsResponse(BaseModel):
|
22 |
+
title: str
|
23 |
+
subhead: str
|
24 |
+
content: str
|
25 |
+
sources: list[str] # Lista de URLs/links utilizados
|
26 |
+
|
27 |
+
def get_brazilian_date_string():
|
28 |
+
"""
|
29 |
+
Retorna a data atual formatada em português brasileiro.
|
30 |
+
Implementa fallbacks robustos para diferentes sistemas operacionais.
|
31 |
+
"""
|
32 |
+
try:
|
33 |
+
# Tenta configurar o locale brasileiro
|
34 |
+
locale_variants = [
|
35 |
+
'pt_BR.UTF-8', # Linux/macOS padrão
|
36 |
+
'pt_BR.utf8', # Algumas distribuições Linux
|
37 |
+
'pt_BR', # Fallback sem encoding
|
38 |
+
'Portuguese_Brazil.1252', # Windows
|
39 |
+
'Portuguese_Brazil', # Windows alternativo
|
40 |
+
'pt_BR.ISO8859-1', # Encoding alternativo
|
41 |
+
]
|
42 |
+
|
43 |
+
locale_set = False
|
44 |
+
for loc in locale_variants:
|
45 |
+
try:
|
46 |
+
locale.setlocale(locale.LC_TIME, loc)
|
47 |
+
locale_set = True
|
48 |
+
logger.info(f"Locale configurado com sucesso: {loc}")
|
49 |
+
break
|
50 |
+
except locale.Error:
|
51 |
+
logger.debug(f"Locale {loc} não disponível")
|
52 |
+
continue
|
53 |
+
|
54 |
+
if not locale_set:
|
55 |
+
logger.warning("Nenhum locale brasileiro encontrado, usando locale padrão")
|
56 |
+
locale.setlocale(locale.LC_TIME, '') # Usa locale padrão do sistema
|
57 |
+
|
58 |
+
# Obtém a data atual no fuso horário de São Paulo
|
59 |
+
now = datetime.now(ZoneInfo("America/Sao_Paulo"))
|
60 |
+
|
61 |
+
# Dicionários para tradução manual (fallback caso locale não funcione)
|
62 |
+
meses = {
|
63 |
+
1: 'janeiro', 2: 'fevereiro', 3: 'março', 4: 'abril',
|
64 |
+
5: 'maio', 6: 'junho', 7: 'julho', 8: 'agosto',
|
65 |
+
9: 'setembro', 10: 'outubro', 11: 'novembro', 12: 'dezembro'
|
66 |
+
}
|
67 |
+
|
68 |
+
dias_semana = {
|
69 |
+
0: 'segunda-feira', 1: 'terça-feira', 2: 'quarta-feira',
|
70 |
+
3: 'quinta-feira', 4: 'sexta-feira', 5: 'sábado', 6: 'domingo'
|
71 |
+
}
|
72 |
+
|
73 |
+
try:
|
74 |
+
# Tenta usar strftime com locale configurado
|
75 |
+
if locale_set:
|
76 |
+
# Para sistemas Unix/Linux usa %-d (sem zero à esquerda)
|
77 |
+
try:
|
78 |
+
date_string = now.strftime("%-d de %B de %Y (%A)")
|
79 |
+
except ValueError:
|
80 |
+
# Para Windows usa %#d
|
81 |
+
try:
|
82 |
+
date_string = now.strftime("%#d de %B de %Y (%A)")
|
83 |
+
except ValueError:
|
84 |
+
# Fallback: usa %d e remove zero manualmente se necessário
|
85 |
+
date_string = now.strftime("%d de %B de %Y (%A)")
|
86 |
+
if date_string.startswith('0'):
|
87 |
+
date_string = date_string[1:]
|
88 |
+
|
89 |
+
# Força dia da semana em minúscula
|
90 |
+
date_string = date_string.replace(date_string.split('(')[1].split(')')[0],
|
91 |
+
date_string.split('(')[1].split(')')[0].lower())
|
92 |
+
else:
|
93 |
+
# Fallback manual completo
|
94 |
+
dia = now.day
|
95 |
+
mes = meses[now.month]
|
96 |
+
ano = now.year
|
97 |
+
dia_semana = dias_semana[now.weekday()]
|
98 |
+
date_string = f"{dia} de {mes} de {ano} ({dia_semana})"
|
99 |
+
|
100 |
+
except Exception as e:
|
101 |
+
logger.warning(f"Erro ao formatar data com locale: {e}")
|
102 |
+
# Fallback manual completo em caso de erro
|
103 |
+
dia = now.day
|
104 |
+
mes = meses[now.month]
|
105 |
+
ano = now.year
|
106 |
+
dia_semana = dias_semana[now.weekday()]
|
107 |
+
date_string = f"{dia} de {mes} de {ano} ({dia_semana})"
|
108 |
+
|
109 |
+
logger.info(f"Data detectada e formatada: {date_string}")
|
110 |
+
return date_string
|
111 |
+
|
112 |
+
except Exception as e:
|
113 |
+
logger.error(f"Erro crítico na formatação de data: {e}")
|
114 |
+
# Último fallback: data em inglês
|
115 |
+
now = datetime.now(ZoneInfo("America/Sao_Paulo"))
|
116 |
+
date_string = now.strftime("%d de %B de %Y")
|
117 |
+
logger.warning(f"Usando fallback de data: {date_string}")
|
118 |
+
return date_string
|
119 |
+
|
120 |
+
def safe_json_serialize(obj):
|
121 |
+
"""Converte objeto em JSON de forma segura para logging"""
|
122 |
+
try:
|
123 |
+
return json.dumps(obj, indent=2, ensure_ascii=False, default=str)
|
124 |
+
except Exception as e:
|
125 |
+
logger.warning(f"Erro ao serializar objeto para JSON: {e}")
|
126 |
+
return str(obj)
|
127 |
+
|
128 |
+
def extract_text_from_response(response):
|
129 |
+
"""
|
130 |
+
Extrai o texto da resposta de forma robusta, lidando com diferentes estruturas possíveis.
|
131 |
+
"""
|
132 |
+
response_text = ""
|
133 |
+
|
134 |
+
# Método 1: Tentar response.text diretamente
|
135 |
+
if hasattr(response, 'text') and response.text:
|
136 |
+
response_text = response.text
|
137 |
+
logger.info(f"Texto extraído via response.text: {len(response_text)} caracteres")
|
138 |
+
return response_text
|
139 |
+
|
140 |
+
# Método 2: Tentar extrair dos candidates
|
141 |
+
if hasattr(response, 'candidates') and response.candidates:
|
142 |
+
for i, candidate in enumerate(response.candidates):
|
143 |
+
logger.debug(f"Processando candidate {i}")
|
144 |
+
|
145 |
+
if not hasattr(candidate, 'content') or not candidate.content:
|
146 |
+
logger.debug(f"Candidate {i} não tem content válido")
|
147 |
+
continue
|
148 |
+
|
149 |
+
content = candidate.content
|
150 |
+
|
151 |
+
# Verificar se content tem parts e se não é None
|
152 |
+
if not hasattr(content, 'parts') or content.parts is None:
|
153 |
+
logger.debug(f"Candidate {i} content.parts é None ou não existe")
|
154 |
+
continue
|
155 |
+
|
156 |
+
# Iterar pelas parts de forma segura
|
157 |
+
try:
|
158 |
+
parts_list = list(content.parts) if content.parts else []
|
159 |
+
logger.debug(f"Candidate {i} tem {len(parts_list)} parts")
|
160 |
+
|
161 |
+
for j, part in enumerate(parts_list):
|
162 |
+
if hasattr(part, 'text') and part.text:
|
163 |
+
response_text += part.text
|
164 |
+
logger.debug(f"Texto adicionado do candidate {i}, part {j}: {len(part.text)} chars")
|
165 |
+
|
166 |
+
except Exception as e:
|
167 |
+
logger.warning(f"Erro ao processar parts do candidate {i}: {e}")
|
168 |
+
continue
|
169 |
+
|
170 |
+
# Método 3: Tentar atributos alternativos
|
171 |
+
if not response_text and hasattr(response, 'candidates'):
|
172 |
+
for i, candidate in enumerate(response.candidates):
|
173 |
+
for attr_name in ['output', 'generated_text', 'text_content']:
|
174 |
+
if hasattr(candidate, attr_name):
|
175 |
+
attr_value = getattr(candidate, attr_name)
|
176 |
+
if attr_value and str(attr_value).strip():
|
177 |
+
response_text += str(attr_value)
|
178 |
+
logger.info(f"Texto encontrado em candidate.{attr_name}")
|
179 |
+
break
|
180 |
+
|
181 |
+
logger.info(f"Texto total extraído: {len(response_text)} caracteres")
|
182 |
+
return response_text
|
183 |
+
|
184 |
+
def extract_sources_from_response(response):
|
185 |
+
"""
|
186 |
+
Extrai as fontes (URLs) do grounding metadata de forma robusta.
|
187 |
+
"""
|
188 |
+
sources = []
|
189 |
+
|
190 |
+
if not (hasattr(response, 'candidates') and response.candidates):
|
191 |
+
logger.debug("Response não tem candidates")
|
192 |
+
return sources
|
193 |
+
|
194 |
+
for i, candidate in enumerate(response.candidates):
|
195 |
+
if not (hasattr(candidate, 'grounding_metadata') and candidate.grounding_metadata):
|
196 |
+
logger.debug(f"Candidate {i} não tem grounding_metadata")
|
197 |
+
continue
|
198 |
+
|
199 |
+
grounding_metadata = candidate.grounding_metadata
|
200 |
+
logger.debug(f"Processando grounding_metadata do candidate {i}")
|
201 |
+
|
202 |
+
# Verificar grounding_chunks
|
203 |
+
if hasattr(grounding_metadata, 'grounding_chunks') and grounding_metadata.grounding_chunks:
|
204 |
+
logger.debug(f"Encontrados {len(grounding_metadata.grounding_chunks)} grounding_chunks")
|
205 |
+
|
206 |
+
for j, chunk in enumerate(grounding_metadata.grounding_chunks):
|
207 |
+
try:
|
208 |
+
if (hasattr(chunk, 'web') and chunk.web and
|
209 |
+
hasattr(chunk.web, 'uri') and chunk.web.uri):
|
210 |
+
|
211 |
+
uri = chunk.web.uri
|
212 |
+
if uri and uri not in sources:
|
213 |
+
sources.append(uri)
|
214 |
+
logger.debug(f"Fonte adicionada do chunk {j}: {uri}")
|
215 |
+
|
216 |
+
except Exception as e:
|
217 |
+
logger.warning(f"Erro ao processar chunk {j}: {e}")
|
218 |
+
continue
|
219 |
+
|
220 |
+
# Verificar search_entry_point (método alternativo)
|
221 |
+
if hasattr(grounding_metadata, 'search_entry_point') and grounding_metadata.search_entry_point:
|
222 |
+
logger.debug("Search entry point encontrado")
|
223 |
+
# Aqui você pode adicionar lógica adicional se necessário
|
224 |
+
|
225 |
+
logger.info(f"Total de fontes únicas encontradas: {len(sources)}")
|
226 |
+
return sources
|
227 |
+
|
228 |
+
@router.post("/rewrite-news", response_model=NewsResponse)
|
229 |
+
async def rewrite_news(news: NewsRequest):
|
230 |
+
"""
|
231 |
+
Endpoint para reescrever notícias usando o modelo Gemini.
|
232 |
+
"""
|
233 |
+
try:
|
234 |
+
logger.info(f"Iniciando reescrita de notícia com {len(news.content)} caracteres")
|
235 |
+
|
236 |
+
# Verificar se a API key está configurada
|
237 |
+
api_key = os.environ.get("GEMINI_API_KEY")
|
238 |
+
if not api_key:
|
239 |
+
logger.error("GEMINI_API_KEY não encontrada nas variáveis de ambiente")
|
240 |
+
raise HTTPException(status_code=500, detail="API key não configurada")
|
241 |
+
|
242 |
+
client = genai.Client(api_key=api_key)
|
243 |
+
model = "gemini-2.5-pro"
|
244 |
+
|
245 |
+
# Obter data formatada
|
246 |
+
date_string = get_brazilian_date_string()
|
247 |
+
logger.info(f"Usando data: {date_string}")
|
248 |
+
|
249 |
+
# Instruções do sistema
|
250 |
+
SYSTEM_INSTRUCTIONS = f"""
|
251 |
+
Você é um jornalista de entretenimento brasileiro, escrevendo para portais digitais modernos. Sua missão é transformar notícias internacionais em matérias originais, atualizadas e envolventes para o público brasileiro.
|
252 |
+
|
253 |
+
ESTILO DE ESCRITA:
|
254 |
+
|
255 |
+
- Tom natural, direto e conversacional, sem frases feitas ou jargões jornalísticos
|
256 |
+
- Varie estruturas e conectivos para evitar robotização. Ou seja, sem frases redundantes, exemplo: "destacando como a experiência pode ser um divisor de águas profissional", "reafirma a força criativa do país no cenário global", "A revelação contextualizou não apenas sua performance na dança, mas também". É para noticiar a notícia de fato, sempre buscando mais informações que se conectam, e não opiniões pessoais.
|
257 |
+
- Sem linguagem militante ou juízos morais: mantenha tom informativo e predominantemente conservador
|
258 |
+
- Evite opiniões genéricas; foque em fatos e contexto
|
259 |
+
|
260 |
+
CONTEÚDO E ABORDAGEM:
|
261 |
+
|
262 |
+
- Use a notícia-base como ponto de partida, não como roteiro
|
263 |
+
- PESQUISE SEMPRE usando a ferramenta de busca, Variety, THR, Deadline e fontes confiáveis em inglês
|
264 |
+
- Traga dados extras, contexto histórico e informações relevantes
|
265 |
+
- Sempre que possível, inclua títulos de obras em Português do Brasil
|
266 |
+
|
267 |
+
ESTRUTURA E FORMATAÇÃO:
|
268 |
+
|
269 |
+
- Comece com um lead que já entrega o essencial
|
270 |
+
- Desenvolva com profundidade, conectando com outros fatos e entregando contexto completo, principalmente no cenário brasileiro
|
271 |
+
- Termine com uma informação concreta (data, lançamento, próximos passos), nunca com opinião genérica. Ou seja, a conclusão da noticia deve ser com mais noticia, sem redundância genérica e robótica
|
272 |
+
- Otimize para leitura digital e SEO (parágrafos bem segmentados, palavras chaves, etc)
|
273 |
+
|
274 |
+
FORMATO:
|
275 |
+
|
276 |
+
<headline>título aqui</headline>
|
277 |
+
<subhead>subtítulo aqui</subhead>
|
278 |
+
<body>conteúdo aqui</body>
|
279 |
+
|
280 |
+
Use <strong> para destaques e <em> para títulos de obras ou citações. Apenas. Mais tags estão estritamente proibidas.
|
281 |
+
|
282 |
+
TÍTULOS:
|
283 |
+
|
284 |
+
- Padrão brasileiro: só a primeira palavra em maiúscula (exceto nomes próprios e títulos de filmes/séries)
|
285 |
+
- Seja claro, direto e específico... Se houver, não inclua spoilers no título, apenas no body
|
286 |
+
|
287 |
+
OBRIGAÇÕES:
|
288 |
+
|
289 |
+
1. É obrigatório usar a ferramenta de busca para complementar a notícia se no prompt, contiver /google.
|
290 |
+
2. Traga sempre informações novas e contextualizadas.
|
291 |
+
|
292 |
+
O resultado deve soar como uma matéria escrita por um profissional experiente, não por IA. Seja preciso, atual e interessante. Sempre pesquise informações adicionais se no prompt contiver /google, isso é obrigatório. Sempre complete a notícia com acontecimentos que se ligam, sempre contextualize tudo para o leitor. A data de hoje é {date_string}
|
293 |
+
"""
|
294 |
+
|
295 |
+
# Primeiro exemplo
|
296 |
+
EXAMPLE_INPUT_1 = """
|
297 |
+
/google News base: Ed Helms revealed in an interview that he was nervous about his parents' reaction to the film The Hangover, but in the end everything worked out and her mother loved the movie. The article is out of date, more information is needed.
|
298 |
+
"""
|
299 |
+
|
300 |
+
EXAMPLE_OUTPUT_1 = """<headline>"Se Beber, Não Case!": Ed Helms, o Dr. Stuart, revela medo do que os pais iriam pensar, mas tudo deu certo</headline>
|
301 |
+
<subhead>Em uma carreira repleta de surpresas e sucesso internacional, o ator relembra o nervosismo que antecedeu a estreia da comédia que o tornou famoso.</subhead>
|
302 |
+
<body>
|
303 |
+
<p><strong>Ed Helms</strong> nunca escondeu o fato de que sua participação em <strong>Se Beber, Não Case!</strong> foi um choque cultural, especialmente para seus pais. Em uma entrevista recente ao podcast de <strong>Ted Danson</strong>, <em>Where Everybody Knows Your Name</em>, o ator falou sobre a ansiedade que sentiu ao imaginar a reação da família à comédia para maiores que o transformou em astro de cinema.</p>
|
304 |
+
<p>Helms, que foi criado em um lar sulista com valores socialmente conservadores, revelou que, embora o ambiente fosse politicamente progressista, algumas situações, como dentes arrancados, casamentos embriagados e até tigres no banheiro, eram muito diferentes do que seus pais consideravam apropriado. O ator brincou: <em>"Não foi pra isso que me criaram"</em>, fazendo alusão ao enredo caótico do filme de 2009. Ele acrescentou que, embora seus pais já tivessem assistido a algumas de suas performances em programas como <em>The Daily Show</em> e <em>The Office</em>, o que ajudou a criar certa tolerância, o filme ainda o deixava nervoso.</p>
|
305 |
+
<p>Estrelando sua primeira grande produção, Helms levou os pais para a estreia quando tinha 35 anos. No entanto, foi surpreendido ao ver sua mãe chorando quando as luzes se acenderam. <em>"Pensei: 'Pronto. Acabei de partir o coração da minha mãe'"</em>, recordou. O momento de tensão, porém, durou pouco: ela o tranquilizou dizendo que o filme havia sido hilário.</p>
|
306 |
+
<p><strong>Se Beber, Não Case!</strong>, dirigido por <strong>Todd Phillips</strong>, foi um sucesso comercial, arrecadando aproximadamente <strong>469 milhões de dólares</strong> em todo o mundo e se tornando a comédia para maiores de classificação indicativa de maior bilheteria até então. A popularidade do filme resultou em duas sequências, lançadas em 2011 e 2013, e consolidou o "bando de lobos" formado por <strong>Helms</strong>, <strong>Bradley Cooper</strong> e <strong>Zach Galifianakis</strong> como um dos times cômicos mais icônicos do cinema moderno.</p>
|
307 |
+
<p>Sobre a possibilidade de um quarto filme, <strong>Bradley Cooper</strong> afirmou em 2023 que toparia participar sem hesitar, principalmente pela chance de reencontrar colegas e diretor. Ainda assim, reconheceu que o projeto é improvável, já que <strong>Phillips</strong> está atualmente focado em empreendimentos de maior escala, como a série de filmes <em>Coringa</em>.</p>
|
308 |
+
</body>"""
|
309 |
+
|
310 |
+
# Segundo exemplo
|
311 |
+
EXAMPLE_INPUT_2 = """
|
312 |
+
/google News base: The Office spinoff series 'The Paper' has set a September premiere date at Peacock.
|
313 |
+
|
314 |
+
The new mockumentary series from Greg Daniels and Michael Koman will debut Sept. 4 on Peacock, the streamer announced Thursday. The first four episodes of 'The Paper' will premiere on Sept. 4, with two new episodes dropping every Thursday through Sept. 25.
|
315 |
+
|
316 |
+
'The Paper' follows the documentary crew that immortalized Dunder Mifflin's Scranton branch in 'The Office' as they find a new subject when they discover a historic Midwestern newspaper and the publisher trying to revive it, according to the official logline.
|
317 |
+
|
318 |
+
'The Office' fan-favorite Oscar Nuñez returns to the franchise in 'The Paper,' joining series regulars Domhnall Gleeson, Sabrina Impacciatore, Chelsea Frei, Melvin Gregg, Gbemisola Ikumelo, Alex Edelman, Ramona Young and Tim Key.
|
319 |
+
|
320 |
+
Guest stars for the show include Eric Rahill, Tracy Letts, Molly Ephraim, Mo Welch, Allan Havey, Duane Shepard Sr., Nate Jackson and Nancy Lenehan.
|
321 |
+
|
322 |
+
'The Paper' was created by Daniels, who created 'The Office,' under his banner Deedle-Dee Productions, and Koman, who has written on 'Nathan for You' and 'SNL.' Produced by Universal Television, a division of Universal Studio Group, 'The Paper' is executive produced by Ricky Gervais, Stephen Merchant, Howard Klein, Ben Silverman and Banijay Americas (formerly Reveille).
|
323 |
+
|
324 |
+
Daniels serves as a director on the show alongside Ken Kwapis, Yana Gorskaya, Paul Lieberstein, Tazbah Chavez, Jason Woliner, Jennifer Celotta, Matt Sohn, Dave Rogers and Jeff Blitz.
|
325 |
+
|
326 |
+
'The Office' launched in 2005 on NBC and ran for nine seasons leading up to the series finale in 2013. The cast of the beloved sitcom included Steve Carell, Rainn Wilson, John Krasinski, Jenna Fischer, Mindy Kaling and B.J. Novak, among others. The article is out of date, more information is needed.
|
327 |
+
"""
|
328 |
+
|
329 |
+
EXAMPLE_OUTPUT_2 = """<headline>Nova série do universo 'The Office' ganha título, data de estreia e um rosto familiar</headline>
|
330 |
+
<subhead>Intitulada 'The Paper', produção de Greg Daniels e Michael Koman chega em setembro com Domhnall Gleeson, Sabrina Impacciatore e o retorno de Oscar Nuñez</subhead>
|
331 |
+
<body>
|
332 |
+
<p>A equipe original de documentaristas de <em>"Insane Daily Life at Dunder Mifflin"</em> voltou ao trabalho, desta vez mudando para uma nova história, três anos após o fim de <em>"The Office"</em>. Após uma década de espera, o derivado da amada série de comédia finalmente saiu do papel e será lançado em <strong>4 de setembro de 2025</strong>. O nome do derivado é <em>"The Paper"</em> e estará disponível na plataforma de streaming <strong>Peacock</strong>.</p>
|
333 |
+
<p>A trama agora se desloca da fictícia <strong>Scranton, Pensilvânia</strong>, para o escritório de um jornal histórico, porém problemático, localizado no meio-oeste dos Estados Unidos, focando em um jornal em dificuldades na região. A equipe busca uma nova história após cobrir a vida de <strong>Michael Scott</strong> e <strong>Dwight Schrute</strong>. Agora, a equipe acompanha o <strong>Toledo Truth Teller</strong>, um jornal em <strong>Toledo, Ohio</strong>, e o editor que tenta reviver o jornal com a ajuda de repórteres voluntários.</p>
|
334 |
+
<p>O novo elenco conta com <strong>Domhnall Gleeson</strong>, ator irlandês famoso por <em>"Ex Machina"</em> e <em>"Questão de Tempo"</em>, ao lado da atriz italiana <strong>Sabrina Impacciatore</strong>, que ganhou amplo reconhecimento por seu papel na segunda temporada de <em>"The White Lotus"</em>. Gleeson interpreta o novo editor otimista do jornal, enquanto Impacciatore atua como gerente de redação.</p>
|
335 |
+
<p>Nas entrevistas mais recentes, Gleeson tenta se distanciar das comparações com o gerente da <strong>Dunder Mifflin</strong>. <em>"Acho que se você tentar competir com o que Steve [Carell] ou Ricky Gervais fizeram, seria um enorme erro,"</em> enfatizou o ator, visando construir uma persona totalmente nova. Ele também revelou ter recebido um tipo de conselho de <strong>John Krasinski</strong> e até de <strong>Steve Carell</strong> para aceitar o papel, especialmente porque se tratava de um projeto de <strong>Greg Daniels</strong>.</p>
|
336 |
+
<p>Como <em>"The Paper"</em> está reintroduzindo os personagens originais, os fãs de longa data da série parecem estar encantados, já que também traz <strong>Oscar Nuñez</strong> reprisando seu papel como o contador <strong>Oscar Martinez</strong>. Oscar, que estava iniciando uma carreira política em <em>"The Office"</em>, agora parece ter se mudado para <strong>Toledo</strong>. <em>"Eu disse ao Sr. Greg Daniels que, se Oscar voltasse, ele provavelmente estaria morando em uma cidade mais agitada e cosmopolita. Greg me ouviu e mudou Oscar para Toledo, Ohio, que tem três vezes a população de Scranton. Então, foi bom ser ouvido"</em>, brincou Nuñez durante um evento da <strong>NBCUniversal</strong>.</p>
|
337 |
+
<p><strong>Greg Daniels</strong>, que anteriormente adaptou <em>"The Office"</em> para o público americano, está em parceria com <strong>Michael Koman</strong>, cocriador de <em>"Nathan for You"</em>, para este novo projeto. Koman e Daniels, junto com <strong>Ricky Gervais</strong> e <strong>Stephen Merchant</strong>, criadores da série britânica original, formam a equipe de produção executiva.</p>
|
338 |
+
<p>A primeira temporada de <em>"The Paper"</em> será dividida em <strong>dez episódios</strong>. Nos Estados Unidos, os <strong>quatro primeiros episódios</strong> estarão disponíveis para streaming em <strong>4 de setembro</strong>. Depois disso, os episódios restantes serão lançados no formato de <strong>dois episódios por semana</strong>, com um total de seis episódios liberados até o final em <strong>25 de setembro</strong>.</p>
|
339 |
+
<p>A série ainda não tem data de estreia confirmada no Brasil, mas a expectativa é de que seja lançada no <strong>Universal+</strong>, serviço de streaming que costuma exibir produções do catálogo da <strong>Peacock</strong>.</p>
|
340 |
+
</body>"""
|
341 |
+
|
342 |
+
# Terceiro exemplo
|
343 |
+
EXAMPLE_INPUT_3 = """
|
344 |
+
/google News base: The first trailer for "Eyes of Wakanda," a four-episode limited animated series executive produced by "Black Panther" director Ryan Coogler, has been released. It will release on Disney+ on Aug. 1.
|
345 |
+
|
346 |
+
The upcoming show will be set in Wakanda, the home of the Black Panther that was extensively explored in Coogler's two Marvel movies. The show will follow "the adventures of brave Wakandan warriors throughout history. In this globe-trotting adventure, the heroes must carry-out dangerous missions to retrieve Vibranium artifacts from the enemies of Wakanda. They are the Hatut Zaraze and this is their story."
|
347 |
+
|
348 |
+
The voice cast includes Winnie Harlow, Cress Williams, Patricia Belcher, Larry Herron, Adam Gold, Lynn Whitfield, Jacques Colimon, Jona Xiao, Isaac Robinson-Smith, Gary Anthony Williams, Zeke Alton, Steve Toussaint and Anika Noni Rose.
|
349 |
+
|
350 |
+
Popular on Variety
|
351 |
+
|
352 |
+
The last time fans saw Wakanda was in 2022's "Black Panther: Wakanda Forever," the sequel to Marvel's 2018 best picture nominee. That movie introduced the underwater world of Atlantis and featured the returns of Letitia Wright, Lupita Nyong'o, Angela Bassett, Winston Duke, Danai Gurira and Martin Freeman. Wakandans have popped up in other places in the MCU, including an appearance by Ayo, played by Florence Kasumba, in the Disney+ series "The Falcon and The Winter Soldier." The second-in-command of the Dora Milaje, Ayo first appeared in "Captain America: Civil War."
|
353 |
+
|
354 |
+
The "Wakanda" series is the first in a multi-year overall TV deal with Coogler's Proximity production company and The Walt Disney Company, with other TV projects in currently in development for the MCU.
|
355 |
+
|
356 |
+
"Eyes of Wakanda" is helmed by director/executive producer Todd Harris, who was first a storyboard artist on "Black Panther" and "Black Panther: Wakanda Forever" then as an illustrator on "Sinners" before directing "Eyes of Wakanda." The show is executive produced by Coogler, Brad Winderbaum, Kevin Feige, Louis D'Esposito and Dana Vasquez-Eberhardt.
|
357 |
+
|
358 |
+
The article is out of date, more information is needed.
|
359 |
+
"""
|
360 |
+
|
361 |
+
EXAMPLE_OUTPUT_3 = """<headline>Olhos de Wakanda: nova animação da Marvel ganha trailer e data de estreia</headline>
|
362 |
+
<subhead>A série de Ryan Coogler vai mergulhar na história do reino de T'Challa e nas missões secretas de seus guerreiros para proteger o vibranium.</subhead>
|
363 |
+
<body>
|
364 |
+
<p>A <strong>Marvel Studios</strong> acaba de publicar o primeiro trailer da sua próxima série animada, <em>Olhos de Wakanda</em>, que explora a história do avançado reino africano. Foi anunciado que a série terá produção executiva de <strong>Ryan Coogler</strong>, diretor dos filmes do <strong>Pantera Negra</strong>, e será lançada como uma minissérie em quatro partes no dia <strong>1º de agosto</strong> no <strong>Disney Plus</strong>. A data é um pouco surpreendente, pois o lançamento estava inicialmente marcado para <strong>27 de agosto</strong>.</p>
|
365 |
+
<p>A narrativa acompanhará as façanhas dos <strong>Hatut Zaraze</strong>, os guerreiros de elite de <strong>Wakanda</strong>, enquanto embarcam em missões globais para recuperar artefatos de <strong>vibranium</strong> roubados. Conhecida nos quadrinhos como os <em>"Cães de Guerra"</em>, essa força secreta atua como uma agência clandestina, protegendo os recursos e inovações ocultas da nação. Os ancestrais de <strong>T'Challa</strong> serão ainda mais explorados na série, mostrando como eles, junto de seus descendentes, forjaram o legado do <strong>Pantera Negra</strong>, que ele um dia herdará, ao longo de séculos de defesa, em arcos cronológicos marcantes.</p>
|
366 |
+
<p><strong>Todd Harris</strong>, que trabalhou como artista de storyboard nos filmes da franquia, criou e dirigiu a obra, que terá um visual distinto, separado das adaptações. Segundo Harris, a trama integra espionagem com eventos históricos, começando no final da <strong>Idade do Bronze</strong> e se estendendo por séculos. O diretor acrescentou: <em>"Você tem um James Bond com a qualidade de Wakanda e às vezes uma Jane Bond com toda a grandiosidade visual e cultural que a nação representa."</em></p>
|
367 |
+
<p><strong>Winnie Harlow</strong>, <strong>Cress Williams</strong>, <strong>Patricia Belcher</strong>, <strong>Lynn Whitfield</strong> e <strong>Anika Noni Rose</strong> fazem parte do elenco de vozes. Além de expandir a mitologia de <strong>Wakanda</strong> dentro do <strong>MCU</strong>, a série também apresentará uma versão ancestral do herói <strong>Punho de Ferro</strong>. <strong>Brad Winderbaum</strong>, chefe de TV e streaming da <strong>Marvel Studios</strong>, confirmou que <em>Olhos de Wakanda</em> estará profundamente integrada com a linha do tempo principal do <strong>MCU</strong>.</p>
|
368 |
+
<p>A produtora de <strong>Coogler</strong>, <strong>Proximity Media</strong>, e a <strong>Disney</strong> têm um acordo televisivo de vários anos que inclui a produção de outros projetos do <strong>MCU</strong>. No dia da estreia, todos os quatro episódios de <em>Olhos de Wakanda</em> estarão disponíveis ao mesmo tempo.</p>
|
369 |
+
</body>"""
|
370 |
+
|
371 |
+
# Configuração correta da ferramenta de pesquisa
|
372 |
+
grounding_tool = types.Tool(
|
373 |
+
google_search=types.GoogleSearch()
|
374 |
+
)
|
375 |
+
|
376 |
+
config = types.GenerateContentConfig(
|
377 |
+
system_instruction=SYSTEM_INSTRUCTIONS,
|
378 |
+
thinking_config=types.ThinkingConfig(
|
379 |
+
thinking_budget=-1,
|
380 |
+
),
|
381 |
+
tools=[grounding_tool], # Use a ferramenta corretamente
|
382 |
+
response_mime_type="text/plain",
|
383 |
+
max_output_tokens=4096,
|
384 |
+
temperature=0.8,
|
385 |
+
)
|
386 |
+
|
387 |
+
# Conteúdo da conversa
|
388 |
+
contents = [
|
389 |
+
# Primeiro exemplo: usuário envia uma notícia
|
390 |
+
types.Content(
|
391 |
+
role="user",
|
392 |
+
parts=[
|
393 |
+
types.Part.from_text(text=EXAMPLE_INPUT_1)
|
394 |
+
]
|
395 |
+
),
|
396 |
+
# Primeiro exemplo: modelo responde com o formato correto
|
397 |
+
types.Content(
|
398 |
+
role="model",
|
399 |
+
parts=[
|
400 |
+
types.Part.from_text(text=EXAMPLE_OUTPUT_1)
|
401 |
+
]
|
402 |
+
),
|
403 |
+
# Segundo exemplo: usuário envia outra notícia
|
404 |
+
types.Content(
|
405 |
+
role="user",
|
406 |
+
parts=[
|
407 |
+
types.Part.from_text(text=EXAMPLE_INPUT_2)
|
408 |
+
]
|
409 |
+
),
|
410 |
+
# Segundo exemplo: modelo responde com o formato correto
|
411 |
+
types.Content(
|
412 |
+
role="model",
|
413 |
+
parts=[
|
414 |
+
types.Part.from_text(text=EXAMPLE_OUTPUT_2)
|
415 |
+
]
|
416 |
+
),
|
417 |
+
# Terceiro exemplo: usuário envia outra notícia
|
418 |
+
types.Content(
|
419 |
+
role="user",
|
420 |
+
parts=[
|
421 |
+
types.Part.from_text(text=EXAMPLE_INPUT_3)
|
422 |
+
]
|
423 |
+
),
|
424 |
+
# Terceiro exemplo: modelo responde com o formato correto
|
425 |
+
types.Content(
|
426 |
+
role="model",
|
427 |
+
parts=[
|
428 |
+
types.Part.from_text(text=EXAMPLE_OUTPUT_3)
|
429 |
+
]
|
430 |
+
),
|
431 |
+
# Agora o usuário envia a notícia real para ser reescrita
|
432 |
+
types.Content(
|
433 |
+
role="user",
|
434 |
+
parts=[
|
435 |
+
types.Part.from_text(text=f"/google News base: {news.content}. The article is out of date, more information is needed.")
|
436 |
+
]
|
437 |
+
)
|
438 |
+
]
|
439 |
+
|
440 |
+
logger.info("Iniciando geração de conteúdo com o modelo Gemini")
|
441 |
+
|
442 |
+
# Usar generate_content em vez de generate_content_stream para melhor controle do grounding
|
443 |
+
response = client.models.generate_content(
|
444 |
+
model=model,
|
445 |
+
contents=contents,
|
446 |
+
config=config
|
447 |
+
)
|
448 |
+
|
449 |
+
# =================================
|
450 |
+
# LOGS DETALHADOS DA RESPOSTA BRUTA
|
451 |
+
# =================================
|
452 |
+
logger.info("=== RESPOSTA BRUTA COMPLETA DO GEMINI ===")
|
453 |
+
logger.info(f"Tipo da resposta: {type(response)}")
|
454 |
+
logger.info(f"Resposta completa: {safe_json_serialize(response)}")
|
455 |
+
|
456 |
+
# Log dos atributos principais
|
457 |
+
logger.info(f"response.text: '{response.text if hasattr(response, 'text') else 'ATRIBUTO text NÃO EXISTE'}'")
|
458 |
+
logger.info(f"response.candidates: {safe_json_serialize(response.candidates) if hasattr(response, 'candidates') else 'ATRIBUTO candidates NÃO EXISTE'}")
|
459 |
+
|
460 |
+
# Log detalhado dos candidates - COM PROTEÇÃO CONTRA PARTS = None
|
461 |
+
if hasattr(response, 'candidates') and response.candidates:
|
462 |
+
for i, candidate in enumerate(response.candidates):
|
463 |
+
logger.info(f"=== CANDIDATE {i} ===")
|
464 |
+
logger.info(f"Candidate completo: {safe_json_serialize(candidate)}")
|
465 |
+
|
466 |
+
if hasattr(candidate, 'content'):
|
467 |
+
logger.info(f"Candidate.content: {safe_json_serialize(candidate.content)}")
|
468 |
+
|
469 |
+
if hasattr(candidate.content, 'parts'):
|
470 |
+
if candidate.content.parts is None:
|
471 |
+
logger.warning(f"ATENÇÃO: Candidate {i} content.parts é None!")
|
472 |
+
else:
|
473 |
+
try:
|
474 |
+
parts_list = list(candidate.content.parts)
|
475 |
+
logger.info(f"Candidate {i} tem {len(parts_list)} parts")
|
476 |
+
|
477 |
+
for j, part in enumerate(parts_list):
|
478 |
+
logger.info(f"Part {j}: {safe_json_serialize(part)}")
|
479 |
+
if hasattr(part, 'text'):
|
480 |
+
logger.info(f"Part {j} text: '{part.text}'")
|
481 |
+
|
482 |
+
except Exception as e:
|
483 |
+
logger.error(f"Erro ao processar parts do candidate {i}: {e}")
|
484 |
+
else:
|
485 |
+
logger.warning(f"Candidate {i} content não tem atributo 'parts'")
|
486 |
+
|
487 |
+
if hasattr(candidate, 'finish_reason'):
|
488 |
+
logger.info(f"Finish reason: {candidate.finish_reason}")
|
489 |
+
|
490 |
+
if hasattr(candidate, 'safety_ratings'):
|
491 |
+
logger.info(f"Safety ratings: {safe_json_serialize(candidate.safety_ratings)}")
|
492 |
+
|
493 |
+
if hasattr(candidate, 'grounding_metadata'):
|
494 |
+
logger.info(f"Grounding metadata: {safe_json_serialize(candidate.grounding_metadata)}")
|
495 |
+
|
496 |
+
# Log de outros atributos possíveis
|
497 |
+
for attr in ['usage_metadata', 'prompt_feedback', 'model_version']:
|
498 |
+
if hasattr(response, attr):
|
499 |
+
logger.info(f"response.{attr}: {safe_json_serialize(getattr(response, attr))}")
|
500 |
+
|
501 |
+
logger.info("=== FIM DA RESPOSTA BRUTA ===")
|
502 |
+
|
503 |
+
# Usar as funções seguras para extração
|
504 |
+
response_text = extract_text_from_response(response)
|
505 |
+
sources = extract_sources_from_response(response)
|
506 |
+
|
507 |
+
logger.info(f"Resposta do modelo recebida com {len(response_text)} caracteres")
|
508 |
+
logger.info(f"Encontradas {len(sources)} fontes únicas: {sources}")
|
509 |
+
|
510 |
+
# Verificar se o texto está vazio
|
511 |
+
if not response_text or response_text.strip() == "":
|
512 |
+
logger.error("ERRO CRÍTICO: Texto da resposta está vazio!")
|
513 |
+
raise HTTPException(
|
514 |
+
status_code=500,
|
515 |
+
detail="Modelo não retornou conteúdo válido. Verifique os logs do servidor."
|
516 |
+
)
|
517 |
+
|
518 |
+
# Extração do título, subtítulo e conteúdo usando as tags
|
519 |
+
title_match = re.search(r"<headline>(.*?)</headline>", response_text, re.DOTALL)
|
520 |
+
title = title_match.group(1).strip() if title_match else "Título não encontrado"
|
521 |
+
logger.info(f"Título extraído: '{title}'")
|
522 |
+
|
523 |
+
subhead_match = re.search(r"<subhead>(.*?)</subhead>", response_text, re.DOTALL)
|
524 |
+
subhead = subhead_match.group(1).strip() if subhead_match else "Subtítulo não encontrado"
|
525 |
+
logger.info(f"Subtítulo extraído: '{subhead}'")
|
526 |
+
|
527 |
+
# Extração do conteúdo com lógica mais flexível para <body>
|
528 |
+
body_match = re.search(r"<body>(.*?)</body>", response_text, re.DOTALL)
|
529 |
+
if body_match:
|
530 |
+
content = body_match.group(1).strip()
|
531 |
+
else:
|
532 |
+
body_start_match = re.search(r"<body>(.*)", response_text, re.DOTALL)
|
533 |
+
if body_start_match:
|
534 |
+
content = body_start_match.group(1).strip()
|
535 |
+
else:
|
536 |
+
content = "Conteúdo não encontrado"
|
537 |
+
|
538 |
+
logger.info(f"Conteúdo extraído: {len(content)} caracteres - '{content[:200]}...' (primeiros 200 chars)")
|
539 |
+
|
540 |
+
# Verificações adicionais
|
541 |
+
if title == "Título não encontrado" or subhead == "Subtítulo não encontrado" or content == "Conteúdo não encontrado":
|
542 |
+
logger.warning("Algumas partes do conteúdo não foram encontradas!")
|
543 |
+
logger.warning(f"Texto completo da resposta: '{response_text}'")
|
544 |
+
|
545 |
+
logger.info("Artigo reescrito com sucesso")
|
546 |
+
logger.debug(f"Título extraído: {title[:50]}...")
|
547 |
+
logger.debug(f"Subtítulo extraído: {subhead[:50]}...")
|
548 |
+
logger.debug(f"Conteúdo extraído: {len(content)} caracteres")
|
549 |
+
logger.debug(f"Sources encontradas: {sources}")
|
550 |
+
|
551 |
+
# Log adicional para debug
|
552 |
+
logger.info("=== RESULTADO FINAL ===")
|
553 |
+
logger.info(f"Title: '{title}'")
|
554 |
+
logger.info(f"Subhead: '{subhead}'")
|
555 |
+
logger.info(f"Content length: {len(content)}")
|
556 |
+
logger.info(f"Sources count: {len(sources)}")
|
557 |
+
|
558 |
+
return NewsResponse(title=title, subhead=subhead, content=content, sources=sources)
|
559 |
+
|
560 |
+
except HTTPException:
|
561 |
+
# Re-lança HTTPExceptions para manter o status code correto
|
562 |
+
raise
|
563 |
+
except Exception as e:
|
564 |
+
error_msg = f"Erro na reescrita: {str(e)}"
|
565 |
+
logger.error(error_msg, exc_info=True)
|
566 |
+
raise HTTPException(status_code=500, detail=error_msg)
|
routers/instagram.py
ADDED
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import httpx
|
3 |
+
from typing import Dict, Optional, Tuple
|
4 |
+
from fastapi import APIRouter, HTTPException
|
5 |
+
from pydantic import BaseModel
|
6 |
+
|
7 |
+
router = APIRouter()
|
8 |
+
|
9 |
+
# 📱 Instagram API Config
|
10 |
+
INSTAGRAM_API_BASE = "https://graph.instagram.com/v23.0"
|
11 |
+
INSTAGRAM_PAGE_ID = "17841464166934843" # Seu Page ID
|
12 |
+
INSTAGRAM_TOKEN = os.getenv("INSTAGRAM_ACCESS_TOKEN")
|
13 |
+
|
14 |
+
if not INSTAGRAM_TOKEN:
|
15 |
+
raise ValueError("❌ INSTAGRAM_ACCESS_TOKEN não foi definido nas variáveis de ambiente!")
|
16 |
+
|
17 |
+
# 📝 Modelos de dados
|
18 |
+
class InstagramPost(BaseModel):
|
19 |
+
image_url: str
|
20 |
+
caption: Optional[str] = None
|
21 |
+
|
22 |
+
class PublishResponse(BaseModel):
|
23 |
+
success: bool
|
24 |
+
media_id: Optional[str] = None
|
25 |
+
post_id: Optional[str] = None
|
26 |
+
post_url: Optional[str] = None
|
27 |
+
message: str
|
28 |
+
comment_posted: bool = False
|
29 |
+
comment_id: Optional[str] = None
|
30 |
+
|
31 |
+
def convert_to_bold_unicode(text: str) -> str:
|
32 |
+
"""
|
33 |
+
Converte texto normal para caracteres Unicode sans-serif bold.
|
34 |
+
Usa uma fonte mais próxima da padrão do Instagram.
|
35 |
+
Funciona para letras A-Z, a-z e números 0-9.
|
36 |
+
"""
|
37 |
+
# Mapeamento usando Mathematical Sans-Serif Bold (mais próximo da fonte do Instagram)
|
38 |
+
bold_map = {
|
39 |
+
'A': '𝗔', 'B': '𝗕', 'C': '𝗖', 'D': '𝗗', 'E': '𝗘', 'F': '𝗙', 'G': '𝗚', 'H': '𝗛',
|
40 |
+
'I': '𝗜', 'J': '𝗝', 'K': '𝗞', 'L': '𝗟', 'M': '𝗠', 'N': '𝗡', 'O': '𝗢', 'P': '𝗣',
|
41 |
+
'Q': '𝗤', 'R': '𝗥', 'S': '𝗦', 'T': '𝗧', 'U': '𝗨', 'V': '𝗩', 'W': '𝗪', 'X': '𝗫',
|
42 |
+
'Y': '𝗬', 'Z': '𝗭',
|
43 |
+
|
44 |
+
'a': '𝗮', 'b': '𝗯', 'c': '𝗰', 'd': '𝗱', 'e': '𝗲', 'f': '𝗳', 'g': '𝗴', 'h': '𝗵',
|
45 |
+
'i': '𝗶', 'j': '𝗷', 'k': '𝗸', 'l': '𝗹', 'm': '𝗺', 'n': '𝗻', 'o': '𝗼', 'p': '𝗽',
|
46 |
+
'q': '𝗾', 'r': '𝗿', 's': '𝘀', 't': '𝘁', 'u': '𝘂', 'v': '𝘃', 'w': '𝘄', 'x': '𝘅',
|
47 |
+
'y': '𝘆', 'z': '𝘇',
|
48 |
+
|
49 |
+
'0': '𝟬', '1': '𝟭', '2': '𝟮', '3': '𝟯', '4': '𝟰', '5': '𝟱', '6': '𝟲', '7': '𝟳',
|
50 |
+
'8': '𝟴', '9': '𝟵'
|
51 |
+
}
|
52 |
+
|
53 |
+
return ''.join(bold_map.get(char, char) for char in text)
|
54 |
+
|
55 |
+
def convert_to_italic_unicode(text: str) -> str:
|
56 |
+
"""
|
57 |
+
Converte texto normal para caracteres Unicode sans-serif itálico.
|
58 |
+
Usa uma fonte mais próxima da padrão do Instagram.
|
59 |
+
Funciona para letras A-Z, a-z e números 0-9.
|
60 |
+
"""
|
61 |
+
# Mapeamento usando Mathematical Sans-Serif Italic (mais próximo da fonte do Instagram)
|
62 |
+
italic_map = {
|
63 |
+
'A': '𝘈', 'B': '𝘉', 'C': '𝘊', 'D': '𝘋', 'E': '𝘌', 'F': '𝘍', 'G': '𝘎', 'H': '𝘏',
|
64 |
+
'I': '𝘐', 'J': '𝘑', 'K': '𝘒', 'L': '𝘓', 'M': '𝘔', 'N': '𝘕', 'O': '𝘖', 'P': '𝘗',
|
65 |
+
'Q': '𝘘', 'R': '𝘙', 'S': '𝘚', 'T': '𝘛', 'U': '𝘜', 'V': '𝘝', 'W': '𝘞', 'X': '𝘟',
|
66 |
+
'Y': '𝘠', 'Z': '𝘡',
|
67 |
+
|
68 |
+
'a': '𝘢', 'b': '𝘣', 'c': '𝘤', 'd': '𝘥', 'e': '𝘦', 'f': '𝘧', 'g': '𝘨', 'h': '𝘩',
|
69 |
+
'i': '𝘪', 'j': '𝘫', 'k': '𝘬', 'l': '𝘭', 'm': '𝘮', 'n': '𝘯', 'o': '𝘰', 'p': '𝘱',
|
70 |
+
'q': '𝘲', 'r': '𝘳', 's': '𝘴', 't': '𝘵', 'u': '𝘶', 'v': '𝘷', 'w': '𝘸', 'x': '𝘹',
|
71 |
+
'y': '𝘺', 'z': '𝘻',
|
72 |
+
|
73 |
+
# Números itálicos (mesmo conjunto do negrito, pois não há itálico específico)
|
74 |
+
'0': '𝟬', '1': '𝟭', '2': '𝟮', '3': '𝟯', '4': '𝟰', '5': '𝟱', '6': '𝟲', '7': '𝟳',
|
75 |
+
'8': '𝟴', '9': '𝟵'
|
76 |
+
}
|
77 |
+
|
78 |
+
return ''.join(italic_map.get(char, char) for char in text)
|
79 |
+
|
80 |
+
def clean_html_tags(text: str) -> str:
|
81 |
+
"""
|
82 |
+
Remove todas as tags HTML exceto <strong> e <em>.
|
83 |
+
Converte <h2> para <strong> (negrito).
|
84 |
+
Converte <p> para quebras de linha.
|
85 |
+
Remove completamente outras tags como <li>, <h3>, etc.
|
86 |
+
|
87 |
+
Returns:
|
88 |
+
str: Texto limpo com apenas <strong> e <em>
|
89 |
+
"""
|
90 |
+
if not text:
|
91 |
+
return ""
|
92 |
+
|
93 |
+
import re
|
94 |
+
|
95 |
+
# Converte <h2> para <strong> (mantendo o conteúdo)
|
96 |
+
text = re.sub(r'<h2[^>]*>(.*?)</h2>', r'<strong>\1</strong>', text, flags=re.DOTALL | re.IGNORECASE)
|
97 |
+
|
98 |
+
# Converte <p> para quebras de linha
|
99 |
+
text = re.sub(r'<p[^>]*>(.*?)</p>', r'\1\n\n', text, flags=re.DOTALL | re.IGNORECASE)
|
100 |
+
|
101 |
+
# Converte <br> e <br/> para quebra de linha simples
|
102 |
+
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
|
103 |
+
|
104 |
+
# Remove todas as outras tags HTML, mantendo apenas o conteúdo
|
105 |
+
# Exclui <strong>, </strong>, <em>, </em> da remoção
|
106 |
+
text = re.sub(r'<(?!/?(?:strong|em)\b)[^>]*>', '', text, flags=re.IGNORECASE)
|
107 |
+
|
108 |
+
# Remove quebras de linha excessivas e espaços extras
|
109 |
+
text = re.sub(r'\n\s*\n\s*\n+', '\n\n', text) # Máximo de 2 quebras consecutivas
|
110 |
+
text = re.sub(r'[ \t]+', ' ', text) # Remove espaços/tabs extras
|
111 |
+
text = text.strip()
|
112 |
+
|
113 |
+
return text
|
114 |
+
|
115 |
+
def format_text_for_instagram(text: str) -> Tuple[str, Optional[str]]:
|
116 |
+
"""
|
117 |
+
Formata o texto para o Instagram:
|
118 |
+
1. Limpa tags HTML indesejadas
|
119 |
+
2. Converte <strong> para negrito Unicode
|
120 |
+
3. Converte <em> para itálico Unicode
|
121 |
+
4. Corta se necessário e retorna o texto principal e o resto para comentário
|
122 |
+
|
123 |
+
Returns:
|
124 |
+
Tuple[str, Optional[str]]: (texto_principal, resto_para_comentario)
|
125 |
+
"""
|
126 |
+
if not text:
|
127 |
+
return "", None
|
128 |
+
|
129 |
+
# 🧹 Primeiro, limpa as tags HTML indesejadas
|
130 |
+
text = clean_html_tags(text)
|
131 |
+
|
132 |
+
# 🔤 Converte tags <strong> para negrito Unicode do Instagram
|
133 |
+
import re
|
134 |
+
|
135 |
+
def replace_strong_tags(match):
|
136 |
+
content = match.group(1) # Conteúdo entre as tags <strong>
|
137 |
+
return convert_to_bold_unicode(content)
|
138 |
+
|
139 |
+
def replace_em_tags(match):
|
140 |
+
content = match.group(1) # Conteúdo entre as tags <em>
|
141 |
+
return convert_to_italic_unicode(content)
|
142 |
+
|
143 |
+
# Substitui todas as ocorrências de <strong>conteudo</strong>
|
144 |
+
text = re.sub(r'<strong>(.*?)</strong>', replace_strong_tags, text, flags=re.DOTALL)
|
145 |
+
|
146 |
+
# Substitui todas as ocorrências de <em>conteudo</em>
|
147 |
+
text = re.sub(r'<em>(.*?)</em>', replace_em_tags, text, flags=re.DOTALL)
|
148 |
+
|
149 |
+
max_length = 2200
|
150 |
+
suffix = '\n\n💬 Continua nos comentários!'
|
151 |
+
|
152 |
+
if len(text) <= max_length:
|
153 |
+
return text, None
|
154 |
+
|
155 |
+
cutoff_length = max_length - len(suffix)
|
156 |
+
if cutoff_length <= 0:
|
157 |
+
return suffix.strip(), text
|
158 |
+
|
159 |
+
trimmed = text[:cutoff_length]
|
160 |
+
|
161 |
+
def is_inside_quotes(s: str, index: int) -> bool:
|
162 |
+
"""Verifica se há aspas abertas não fechadas até o índice"""
|
163 |
+
up_to_index = s[:index + 1]
|
164 |
+
quote_count = up_to_index.count('"')
|
165 |
+
return quote_count % 2 != 0
|
166 |
+
|
167 |
+
# Encontra o último ponto final fora de aspas
|
168 |
+
last_valid_dot = -1
|
169 |
+
for i in range(len(trimmed) - 1, -1, -1):
|
170 |
+
if trimmed[i] == '.' and not is_inside_quotes(trimmed, i):
|
171 |
+
last_valid_dot = i
|
172 |
+
break
|
173 |
+
|
174 |
+
if last_valid_dot > 100:
|
175 |
+
main_text = trimmed[:last_valid_dot + 1]
|
176 |
+
remaining_text = text[last_valid_dot + 1:].strip()
|
177 |
+
else:
|
178 |
+
main_text = trimmed
|
179 |
+
remaining_text = text[cutoff_length:].strip()
|
180 |
+
|
181 |
+
final_main_text = f"{main_text}{suffix}"
|
182 |
+
|
183 |
+
return final_main_text, remaining_text if remaining_text else None
|
184 |
+
|
185 |
+
async def post_comment(client: httpx.AsyncClient, post_id: str, comment_text: str) -> Optional[str]:
|
186 |
+
"""
|
187 |
+
Posta um comentário no post do Instagram
|
188 |
+
|
189 |
+
Returns:
|
190 |
+
Optional[str]: ID do comentário se postado com sucesso
|
191 |
+
"""
|
192 |
+
try:
|
193 |
+
comment_url = f"{INSTAGRAM_API_BASE}/{post_id}/comments"
|
194 |
+
headers = {
|
195 |
+
"Content-Type": "application/json",
|
196 |
+
"Authorization": f"Bearer {INSTAGRAM_TOKEN}"
|
197 |
+
}
|
198 |
+
|
199 |
+
comment_payload = {
|
200 |
+
"message": comment_text
|
201 |
+
}
|
202 |
+
|
203 |
+
print(f"💬 Postando comentário no post {post_id}")
|
204 |
+
comment_response = await client.post(
|
205 |
+
comment_url,
|
206 |
+
headers=headers,
|
207 |
+
json=comment_payload
|
208 |
+
)
|
209 |
+
|
210 |
+
if comment_response.status_code == 200:
|
211 |
+
comment_data = comment_response.json()
|
212 |
+
comment_id = comment_data.get("id")
|
213 |
+
print(f"✅ Comentário postado com ID: {comment_id}")
|
214 |
+
return comment_id
|
215 |
+
else:
|
216 |
+
error_detail = comment_response.text
|
217 |
+
print(f"⚠️ Erro ao postar comentário: {error_detail}")
|
218 |
+
return None
|
219 |
+
|
220 |
+
except Exception as e:
|
221 |
+
print(f"⚠️ Erro inesperado ao postar comentário: {str(e)}")
|
222 |
+
return None
|
223 |
+
|
224 |
+
# 🚀 Endpoint principal para publicar no Instagram
|
225 |
+
@router.post("/publish", response_model=PublishResponse)
|
226 |
+
async def publish_instagram_post(post: InstagramPost) -> PublishResponse:
|
227 |
+
"""
|
228 |
+
Publica uma imagem no Instagram em duas etapas:
|
229 |
+
1. Cria o media container
|
230 |
+
2. Publica o post
|
231 |
+
3. Se necessário, posta o resto do texto como comentário
|
232 |
+
"""
|
233 |
+
|
234 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
235 |
+
try:
|
236 |
+
# 📝 Processa o texto da caption
|
237 |
+
main_caption, remaining_text = format_text_for_instagram(post.caption) if post.caption else ("", None)
|
238 |
+
|
239 |
+
# 🎯 ETAPA 1: Criar o media container
|
240 |
+
media_payload = {
|
241 |
+
"image_url": post.image_url
|
242 |
+
}
|
243 |
+
|
244 |
+
# Adiciona caption processada se fornecida
|
245 |
+
if main_caption:
|
246 |
+
media_payload["caption"] = main_caption
|
247 |
+
|
248 |
+
media_url = f"{INSTAGRAM_API_BASE}/{INSTAGRAM_PAGE_ID}/media"
|
249 |
+
headers = {
|
250 |
+
"Content-Type": "application/json",
|
251 |
+
"Authorization": f"Bearer {INSTAGRAM_TOKEN}"
|
252 |
+
}
|
253 |
+
|
254 |
+
print(f"📤 Criando media container para: {post.image_url}")
|
255 |
+
if remaining_text:
|
256 |
+
print(f"✂️ Texto cortado - será postado comentário com {len(remaining_text)} caracteres")
|
257 |
+
|
258 |
+
media_response = await client.post(
|
259 |
+
media_url,
|
260 |
+
headers=headers,
|
261 |
+
json=media_payload
|
262 |
+
)
|
263 |
+
|
264 |
+
if media_response.status_code != 200:
|
265 |
+
error_detail = media_response.text
|
266 |
+
print(f"❌ Erro ao criar media container: {error_detail}")
|
267 |
+
raise HTTPException(
|
268 |
+
status_code=media_response.status_code,
|
269 |
+
detail=f"Erro ao criar media container: {error_detail}"
|
270 |
+
)
|
271 |
+
|
272 |
+
media_data = media_response.json()
|
273 |
+
media_id = media_data.get("id")
|
274 |
+
|
275 |
+
if not media_id:
|
276 |
+
raise HTTPException(
|
277 |
+
status_code=500,
|
278 |
+
detail="ID do media container não retornado"
|
279 |
+
)
|
280 |
+
|
281 |
+
print(f"✅ Media container criado com ID: {media_id}")
|
282 |
+
|
283 |
+
# 🎯 ETAPA 2: Publicar o post
|
284 |
+
publish_payload = {
|
285 |
+
"creation_id": media_id
|
286 |
+
}
|
287 |
+
|
288 |
+
publish_url = f"{INSTAGRAM_API_BASE}/{INSTAGRAM_PAGE_ID}/media_publish"
|
289 |
+
|
290 |
+
print(f"📤 Publicando post com creation_id: {media_id}")
|
291 |
+
publish_response = await client.post(
|
292 |
+
publish_url,
|
293 |
+
headers=headers,
|
294 |
+
json=publish_payload
|
295 |
+
)
|
296 |
+
|
297 |
+
if publish_response.status_code != 200:
|
298 |
+
error_detail = publish_response.text
|
299 |
+
print(f"❌ Erro ao publicar post: {error_detail}")
|
300 |
+
raise HTTPException(
|
301 |
+
status_code=publish_response.status_code,
|
302 |
+
detail=f"Erro ao publicar post: {error_detail}"
|
303 |
+
)
|
304 |
+
|
305 |
+
publish_data = publish_response.json()
|
306 |
+
post_id = publish_data.get("id")
|
307 |
+
|
308 |
+
# 🔗 ETAPA 3: Obter detalhes do post para construir URL
|
309 |
+
post_url = None
|
310 |
+
if post_id:
|
311 |
+
try:
|
312 |
+
# Query para obter o permalink do post
|
313 |
+
post_details_url = f"{INSTAGRAM_API_BASE}/{post_id}?fields=permalink"
|
314 |
+
details_response = await client.get(post_details_url, headers=headers)
|
315 |
+
|
316 |
+
if details_response.status_code == 200:
|
317 |
+
details_data = details_response.json()
|
318 |
+
post_url = details_data.get("permalink")
|
319 |
+
print(f"🔗 Link do post: {post_url}")
|
320 |
+
else:
|
321 |
+
print(f"⚠️ Não foi possível obter o link do post: {details_response.text}")
|
322 |
+
except Exception as e:
|
323 |
+
print(f"⚠️ Erro ao obter link do post: {str(e)}")
|
324 |
+
|
325 |
+
# 💬 ETAPA 4: Postar comentário com o resto do texto (se necessário)
|
326 |
+
comment_posted = False
|
327 |
+
comment_id = None
|
328 |
+
|
329 |
+
if remaining_text and post_id:
|
330 |
+
comment_id = await post_comment(client, post_id, remaining_text)
|
331 |
+
comment_posted = comment_id is not None
|
332 |
+
|
333 |
+
success_message = "Post publicado com sucesso no Instagram!"
|
334 |
+
if comment_posted:
|
335 |
+
success_message += " Texto adicional postado como comentário."
|
336 |
+
elif remaining_text and not comment_posted:
|
337 |
+
success_message += " ATENÇÃO: Não foi possível postar o comentário com o resto do texto."
|
338 |
+
|
339 |
+
print(f"🎉 {success_message} Post ID: {post_id}")
|
340 |
+
|
341 |
+
return PublishResponse(
|
342 |
+
success=True,
|
343 |
+
media_id=media_id,
|
344 |
+
post_id=post_id,
|
345 |
+
post_url=post_url,
|
346 |
+
message=success_message,
|
347 |
+
comment_posted=comment_posted,
|
348 |
+
comment_id=comment_id
|
349 |
+
)
|
350 |
+
|
351 |
+
except httpx.TimeoutException:
|
352 |
+
print("⏰ Timeout na requisição para Instagram API")
|
353 |
+
raise HTTPException(
|
354 |
+
status_code=408,
|
355 |
+
detail="Timeout na comunicação com a API do Instagram"
|
356 |
+
)
|
357 |
+
except httpx.RequestError as e:
|
358 |
+
print(f"🌐 Erro de conexão: {str(e)}")
|
359 |
+
raise HTTPException(
|
360 |
+
status_code=502,
|
361 |
+
detail=f"Erro de conexão: {str(e)}"
|
362 |
+
)
|
363 |
+
except Exception as e:
|
364 |
+
print(f"💥 Erro inesperado: {str(e)}")
|
365 |
+
raise HTTPException(
|
366 |
+
status_code=500,
|
367 |
+
detail=f"Erro interno do servidor: {str(e)}"
|
368 |
+
)
|
routers/memoriam.py
ADDED
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Query, HTTPException
|
2 |
+
from fastapi.responses import StreamingResponse
|
3 |
+
from PIL import Image, ImageDraw, ImageEnhance, ImageFont
|
4 |
+
from io import BytesIO
|
5 |
+
import requests
|
6 |
+
from typing import Optional
|
7 |
+
import logging
|
8 |
+
|
9 |
+
# Configurar logging
|
10 |
+
logging.basicConfig(level=logging.INFO)
|
11 |
+
log = logging.getLogger("memoriam-api")
|
12 |
+
|
13 |
+
router = APIRouter()
|
14 |
+
|
15 |
+
def download_image_from_url(url: str) -> Image.Image:
|
16 |
+
headers = {
|
17 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
|
18 |
+
}
|
19 |
+
response = requests.get(url, headers=headers)
|
20 |
+
if response.status_code != 200:
|
21 |
+
raise HTTPException(status_code=400, detail=f"Imagem não pôde ser baixada. Código {response.status_code}")
|
22 |
+
try:
|
23 |
+
return Image.open(BytesIO(response.content)).convert("RGB")
|
24 |
+
except Exception as e:
|
25 |
+
raise HTTPException(status_code=400, detail=f"Erro ao abrir imagem: {str(e)}")
|
26 |
+
|
27 |
+
def resize_and_crop_to_fill(img: Image.Image, target_width: int, target_height: int) -> Image.Image:
|
28 |
+
img_ratio = img.width / img.height
|
29 |
+
target_ratio = target_width / target_height
|
30 |
+
|
31 |
+
if img_ratio > target_ratio:
|
32 |
+
scale_height = target_height
|
33 |
+
scale_width = int(scale_height * img_ratio)
|
34 |
+
else:
|
35 |
+
scale_width = target_width
|
36 |
+
scale_height = int(scale_width / img_ratio)
|
37 |
+
|
38 |
+
img_resized = img.resize((scale_width, scale_height), Image.LANCZOS)
|
39 |
+
|
40 |
+
left = (scale_width - target_width) // 2
|
41 |
+
top = (scale_height - target_height) // 2
|
42 |
+
right = left + target_width
|
43 |
+
bottom = top + target_height
|
44 |
+
|
45 |
+
return img_resized.crop((left, top, right, bottom))
|
46 |
+
|
47 |
+
def create_bottom_black_gradient(width: int, height: int) -> Image.Image:
|
48 |
+
"""Cria um gradiente preto suave que vai do topo transparente até a metade da imagem preto"""
|
49 |
+
gradient = Image.new("RGBA", (width, height), color=(0, 0, 0, 0))
|
50 |
+
draw = ImageDraw.Draw(gradient)
|
51 |
+
|
52 |
+
for y in range(height):
|
53 |
+
# Gradiente mais suave que começa transparente e vai até metade da imagem
|
54 |
+
ratio = y / height
|
55 |
+
if ratio <= 0.6:
|
56 |
+
# Primeira parte: totalmente transparente
|
57 |
+
alpha = 0
|
58 |
+
elif ratio <= 0.75:
|
59 |
+
# Transição muito suave (60% a 75% da altura)
|
60 |
+
alpha = int(80 * (ratio - 0.6) / 0.15)
|
61 |
+
else:
|
62 |
+
# Final suave (75% a 100% da altura)
|
63 |
+
alpha = int(80 + 50 * (ratio - 0.75) / 0.25)
|
64 |
+
|
65 |
+
# Usar preto puro (0, 0, 0) com alpha mais baixo
|
66 |
+
draw.line([(0, y), (width, y)], fill=(0, 0, 0, alpha))
|
67 |
+
|
68 |
+
return gradient
|
69 |
+
|
70 |
+
def create_top_black_gradient(width: int, height: int) -> Image.Image:
|
71 |
+
"""Cria um gradiente preto suave que vai do fundo transparente até a metade da imagem preto"""
|
72 |
+
gradient = Image.new("RGBA", (width, height), color=(0, 0, 0, 0))
|
73 |
+
draw = ImageDraw.Draw(gradient)
|
74 |
+
|
75 |
+
for y in range(height):
|
76 |
+
# Gradiente mais suave que começa preto e vai até metade da imagem
|
77 |
+
ratio = y / height
|
78 |
+
if ratio <= 0.25:
|
79 |
+
# Primeira parte suave (0% a 25% da altura)
|
80 |
+
alpha = int(80 + 50 * (0.25 - ratio) / 0.25)
|
81 |
+
elif ratio <= 0.4:
|
82 |
+
# Transição muito suave (25% a 40% da altura)
|
83 |
+
alpha = int(80 * (0.4 - ratio) / 0.15)
|
84 |
+
else:
|
85 |
+
# Segunda parte: totalmente transparente
|
86 |
+
alpha = 0
|
87 |
+
|
88 |
+
# Usar preto puro (0, 0, 0) com alpha mais baixo
|
89 |
+
draw.line([(0, y), (width, y)], fill=(0, 0, 0, alpha))
|
90 |
+
|
91 |
+
return gradient
|
92 |
+
|
93 |
+
def draw_text_left_aligned(draw: ImageDraw.Draw, text: str, x: int, y: int, font_path: str, font_size: int):
|
94 |
+
"""Desenha texto alinhado à esquerda com especificações exatas"""
|
95 |
+
try:
|
96 |
+
font = ImageFont.truetype(font_path, font_size)
|
97 |
+
except Exception:
|
98 |
+
font = ImageFont.load_default()
|
99 |
+
|
100 |
+
# Espaçamento entre letras 0% e cor branca
|
101 |
+
draw.text((x, y), text, font=font, fill=(255, 255, 255), spacing=0)
|
102 |
+
|
103 |
+
|
104 |
+
|
105 |
+
def create_canvas(image_url: Optional[str], name: Optional[str], birth: Optional[str], death: Optional[str], text_position: str = "bottom") -> BytesIO:
|
106 |
+
# Dimensões fixas para Instagram
|
107 |
+
width = 1080
|
108 |
+
height = 1350
|
109 |
+
|
110 |
+
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0)) # Fundo transparente
|
111 |
+
|
112 |
+
# Carregar e processar imagem de fundo se fornecida
|
113 |
+
if image_url:
|
114 |
+
try:
|
115 |
+
img = download_image_from_url(image_url)
|
116 |
+
img_bw = ImageEnhance.Color(img).enhance(0.0).convert("RGBA")
|
117 |
+
filled_img = resize_and_crop_to_fill(img_bw, width, height)
|
118 |
+
canvas.paste(filled_img, (0, 0))
|
119 |
+
except Exception as e:
|
120 |
+
log.warning(f"Erro ao carregar imagem: {e}")
|
121 |
+
|
122 |
+
# Aplicar gradiente baseado na posição do texto
|
123 |
+
if text_position.lower() == "top":
|
124 |
+
gradient_overlay = create_top_black_gradient(width, height)
|
125 |
+
else: # bottom
|
126 |
+
gradient_overlay = create_bottom_black_gradient(width, height)
|
127 |
+
|
128 |
+
canvas = Image.alpha_composite(canvas, gradient_overlay)
|
129 |
+
|
130 |
+
# Adicionar logo no canto inferior direito com opacidade
|
131 |
+
try:
|
132 |
+
logo = Image.open("recurve.png").convert("RGBA")
|
133 |
+
logo_resized = logo.resize((120, 22))
|
134 |
+
# Aplicar opacidade à logo
|
135 |
+
logo_with_opacity = Image.new("RGBA", logo_resized.size)
|
136 |
+
logo_with_opacity.paste(logo_resized, (0, 0))
|
137 |
+
# Reduzir opacidade
|
138 |
+
logo_alpha = logo_with_opacity.split()[-1].point(lambda x: int(x * 0.42)) # 42% de opacidade
|
139 |
+
logo_with_opacity.putalpha(logo_alpha)
|
140 |
+
|
141 |
+
logo_padding = 40
|
142 |
+
logo_x = width - 120 - logo_padding
|
143 |
+
logo_y = height - 22 - logo_padding
|
144 |
+
canvas.paste(logo_with_opacity, (logo_x, logo_y), logo_with_opacity)
|
145 |
+
except Exception as e:
|
146 |
+
log.warning(f"Erro ao carregar a logo: {e}")
|
147 |
+
|
148 |
+
draw = ImageDraw.Draw(canvas)
|
149 |
+
|
150 |
+
# Configurar posições baseadas no text_position
|
151 |
+
text_x = 80 # Alinhamento à esquerda com margem
|
152 |
+
|
153 |
+
if text_position.lower() == "top":
|
154 |
+
dates_y = 100
|
155 |
+
name_y = dates_y + 36 + 6 # Ano + espaçamento de 6px + nome
|
156 |
+
else: # bottom
|
157 |
+
dates_y = height - 250
|
158 |
+
name_y = dates_y + 36 + 6 # Ano + espaçamento de 6px + nome
|
159 |
+
|
160 |
+
# Desenhar datas primeiro (se fornecidas)
|
161 |
+
if birth or death:
|
162 |
+
font_path_regular = "fonts/AGaramondPro-Regular.ttf"
|
163 |
+
|
164 |
+
# Construir texto das datas
|
165 |
+
dates_text = ""
|
166 |
+
if birth and death:
|
167 |
+
dates_text = f"{birth} - {death}"
|
168 |
+
elif birth:
|
169 |
+
dates_text = f"{birth}"
|
170 |
+
elif death:
|
171 |
+
dates_text = f"- {death}"
|
172 |
+
|
173 |
+
if dates_text:
|
174 |
+
draw_text_left_aligned(draw, dates_text, text_x, dates_y, font_path_regular, 36)
|
175 |
+
|
176 |
+
# Desenhar nome abaixo das datas
|
177 |
+
if name:
|
178 |
+
font_path = "fonts/AGaramondPro-BoldItalic.ttf"
|
179 |
+
draw_text_left_aligned(draw, name, text_x, name_y, font_path, 87)
|
180 |
+
|
181 |
+
buffer = BytesIO()
|
182 |
+
canvas.save(buffer, format="PNG")
|
183 |
+
buffer.seek(0)
|
184 |
+
return buffer
|
185 |
+
|
186 |
+
@router.get("/cover/memoriam")
|
187 |
+
def get_memoriam_image(
|
188 |
+
image_url: Optional[str] = Query(None, description="URL da imagem de fundo"),
|
189 |
+
name: Optional[str] = Query(None, description="Nome (será exibido em maiúsculas)"),
|
190 |
+
birth: Optional[str] = Query(None, description="Ano de nascimento (ex: 1943)"),
|
191 |
+
death: Optional[str] = Query(None, description="Ano de falecimento (ex: 2023)"),
|
192 |
+
text_position: str = Query("bottom", description="Posição do texto: 'top' ou 'bottom'")
|
193 |
+
):
|
194 |
+
"""
|
195 |
+
Gera imagem de memoriam no formato 1080x1350 (Instagram).
|
196 |
+
Todos os parâmetros são opcionais, mas recomenda-se fornecer pelo menos o nome.
|
197 |
+
O gradiente será aplicado baseado na posição do texto (top ou bottom).
|
198 |
+
"""
|
199 |
+
try:
|
200 |
+
buffer = create_canvas(image_url, name, birth, death, text_position)
|
201 |
+
return StreamingResponse(buffer, media_type="image/png")
|
202 |
+
except Exception as e:
|
203 |
+
raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}")
|
routers/news.py
ADDED
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Query, HTTPException
|
2 |
+
from fastapi.responses import StreamingResponse
|
3 |
+
from PIL import Image, ImageDraw, ImageFont
|
4 |
+
from io import BytesIO
|
5 |
+
import requests
|
6 |
+
from typing import Optional
|
7 |
+
|
8 |
+
router = APIRouter()
|
9 |
+
|
10 |
+
def download_image_from_url(url: str) -> Image.Image:
|
11 |
+
headers = {
|
12 |
+
"User-Agent": (
|
13 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
14 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
15 |
+
"Chrome/115.0.0.0 Safari/537.36"
|
16 |
+
)
|
17 |
+
}
|
18 |
+
try:
|
19 |
+
response = requests.get(url, headers=headers, timeout=10)
|
20 |
+
response.raise_for_status()
|
21 |
+
return Image.open(BytesIO(response.content)).convert("RGBA")
|
22 |
+
except Exception as e:
|
23 |
+
raise HTTPException(status_code=400, detail=f"Erro ao baixar imagem: {url} ({str(e)})")
|
24 |
+
|
25 |
+
def resize_and_crop_to_fill(img: Image.Image, target_width: int, target_height: int) -> Image.Image:
|
26 |
+
img_ratio = img.width / img.height
|
27 |
+
target_ratio = target_width / target_height
|
28 |
+
|
29 |
+
if img_ratio > target_ratio:
|
30 |
+
scale_height = target_height
|
31 |
+
scale_width = int(scale_height * img_ratio)
|
32 |
+
else:
|
33 |
+
scale_width = target_width
|
34 |
+
scale_height = int(scale_width / img_ratio)
|
35 |
+
|
36 |
+
img_resized = img.resize((scale_width, scale_height), Image.LANCZOS)
|
37 |
+
|
38 |
+
left = (scale_width - target_width) // 2
|
39 |
+
top = (scale_height - target_height) // 2
|
40 |
+
right = left + target_width
|
41 |
+
bottom = top + target_height
|
42 |
+
|
43 |
+
return img_resized.crop((left, top, right, bottom))
|
44 |
+
|
45 |
+
def create_gradient_overlay(width: int, height: int, text_position: str = "bottom") -> Image.Image:
|
46 |
+
"""
|
47 |
+
Cria gradiente overlay baseado na posição do texto
|
48 |
+
"""
|
49 |
+
gradient = Image.new("RGBA", (width, height))
|
50 |
+
draw = ImageDraw.Draw(gradient)
|
51 |
+
|
52 |
+
if text_position.lower() == "bottom":
|
53 |
+
# Gradiente para texto embaixo: posição Y:531, altura 835px
|
54 |
+
gradient_start = 531
|
55 |
+
gradient_height = 835
|
56 |
+
|
57 |
+
for y in range(gradient_height):
|
58 |
+
if y + gradient_start < height:
|
59 |
+
# Gradient: 0% transparent -> 46.63% rgba(0,0,0,0.55) -> 100% rgba(0,0,0,0.7)
|
60 |
+
ratio = y / gradient_height
|
61 |
+
if ratio <= 0.4663:
|
62 |
+
# 0% a 46.63%: de transparente para 0.55
|
63 |
+
opacity_ratio = ratio / 0.4663
|
64 |
+
opacity = int(255 * 0.55 * opacity_ratio)
|
65 |
+
else:
|
66 |
+
# 46.63% a 100%: de 0.55 para 0.7
|
67 |
+
opacity_ratio = (ratio - 0.4663) / (1 - 0.4663)
|
68 |
+
opacity = int(255 * (0.55 + (0.7 - 0.55) * opacity_ratio))
|
69 |
+
|
70 |
+
draw.line([(0, y + gradient_start), (width, y + gradient_start)], fill=(0, 0, 0, opacity))
|
71 |
+
|
72 |
+
else: # text_position == "top"
|
73 |
+
# Gradiente para texto no topo: posição Y:0, altura 835px
|
74 |
+
# linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.307045) 16.93%, rgba(0,0,0,0.55) 45.57%, rgba(0,0,0,0.7) 100%)
|
75 |
+
# 0deg significa: 0% = bottom, 100% = top
|
76 |
+
gradient_height = 835
|
77 |
+
|
78 |
+
for y in range(gradient_height):
|
79 |
+
if y < height:
|
80 |
+
# Inverter a ratio: y=0 (topo) deve ser 100% do gradient, y=835 (bottom) deve ser 0%
|
81 |
+
ratio = (gradient_height - y) / gradient_height
|
82 |
+
if ratio <= 0.1693:
|
83 |
+
# 0% a 16.93%: de 0 (transparente) para 0.307
|
84 |
+
opacity_ratio = ratio / 0.1693
|
85 |
+
opacity = int(255 * (0.307 * opacity_ratio))
|
86 |
+
elif ratio <= 0.4557:
|
87 |
+
# 16.93% a 45.57%: de 0.307 para 0.55
|
88 |
+
opacity_ratio = (ratio - 0.1693) / (0.4557 - 0.1693)
|
89 |
+
opacity = int(255 * (0.307 + (0.55 - 0.307) * opacity_ratio))
|
90 |
+
else:
|
91 |
+
# 45.57% a 100%: de 0.55 para 0.7
|
92 |
+
opacity_ratio = (ratio - 0.4557) / (1 - 0.4557)
|
93 |
+
opacity = int(255 * (0.55 + (0.7 - 0.55) * opacity_ratio))
|
94 |
+
|
95 |
+
draw.line([(0, y), (width, y)], fill=(0, 0, 0, opacity))
|
96 |
+
|
97 |
+
return gradient
|
98 |
+
|
99 |
+
def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> list[str]:
|
100 |
+
words = text.split()
|
101 |
+
lines = []
|
102 |
+
current_line = ""
|
103 |
+
|
104 |
+
for word in words:
|
105 |
+
test_line = f"{current_line} {word}".strip()
|
106 |
+
if draw.textlength(test_line, font=font) <= max_width:
|
107 |
+
current_line = test_line
|
108 |
+
else:
|
109 |
+
if current_line:
|
110 |
+
lines.append(current_line)
|
111 |
+
current_line = word
|
112 |
+
if current_line:
|
113 |
+
lines.append(current_line)
|
114 |
+
return lines
|
115 |
+
|
116 |
+
def get_responsive_font_and_lines(text: str, font_path: str, max_width: int, max_lines: int = 3,
|
117 |
+
max_font_size: int = 80, min_font_size: int = 20) -> tuple[ImageFont.FreeTypeFont, list[str], int]:
|
118 |
+
"""
|
119 |
+
Retorna a fonte e linhas ajustadas para caber no número máximo de linhas.
|
120 |
+
"""
|
121 |
+
temp_img = Image.new("RGB", (1, 1))
|
122 |
+
temp_draw = ImageDraw.Draw(temp_img)
|
123 |
+
|
124 |
+
current_font_size = max_font_size
|
125 |
+
|
126 |
+
while current_font_size >= min_font_size:
|
127 |
+
try:
|
128 |
+
font = ImageFont.truetype(font_path, current_font_size)
|
129 |
+
except Exception:
|
130 |
+
font = ImageFont.load_default()
|
131 |
+
|
132 |
+
lines = wrap_text(text, font, max_width, temp_draw)
|
133 |
+
|
134 |
+
if len(lines) <= max_lines:
|
135 |
+
return font, lines, current_font_size
|
136 |
+
|
137 |
+
current_font_size -= 1
|
138 |
+
|
139 |
+
try:
|
140 |
+
font = ImageFont.truetype(font_path, min_font_size)
|
141 |
+
except Exception:
|
142 |
+
font = ImageFont.load_default()
|
143 |
+
|
144 |
+
lines = wrap_text(text, font, max_width, temp_draw)
|
145 |
+
return font, lines, min_font_size
|
146 |
+
|
147 |
+
def get_font_and_lines(text: str, font_path: str, font_size: int, max_width: int) -> tuple[ImageFont.FreeTypeFont, list[str]]:
|
148 |
+
"""
|
149 |
+
Retorna a fonte e linhas com tamanho fixo de fonte.
|
150 |
+
"""
|
151 |
+
try:
|
152 |
+
font = ImageFont.truetype(font_path, font_size)
|
153 |
+
except Exception:
|
154 |
+
font = ImageFont.load_default()
|
155 |
+
|
156 |
+
temp_img = Image.new("RGB", (1, 1))
|
157 |
+
temp_draw = ImageDraw.Draw(temp_img)
|
158 |
+
lines = wrap_text(text, font, max_width, temp_draw)
|
159 |
+
|
160 |
+
return font, lines
|
161 |
+
|
162 |
+
def get_text_color_rgb(text_color: str) -> tuple[int, int, int]:
|
163 |
+
"""
|
164 |
+
Converte o parâmetro text_color para RGB.
|
165 |
+
"""
|
166 |
+
if text_color.lower() == "black":
|
167 |
+
return (0, 0, 0)
|
168 |
+
else: # white por padrão
|
169 |
+
return (255, 255, 255)
|
170 |
+
|
171 |
+
def get_device_dimensions(device: str) -> tuple[int, int]:
|
172 |
+
"""Retorna as dimensões baseadas no dispositivo"""
|
173 |
+
if device.lower() == "web":
|
174 |
+
return (1280, 720)
|
175 |
+
else: # Instagram por padrão
|
176 |
+
return (1080, 1350)
|
177 |
+
|
178 |
+
def create_canvas(image_url: Optional[str], headline: Optional[str], device: str = "ig",
|
179 |
+
text_position: str = "bottom", text_color: str = "white") -> BytesIO:
|
180 |
+
width, height = get_device_dimensions(device)
|
181 |
+
is_web = device.lower() == "web"
|
182 |
+
text_rgb = get_text_color_rgb(text_color)
|
183 |
+
|
184 |
+
# Configurações específicas por dispositivo
|
185 |
+
if is_web:
|
186 |
+
padding_x = 40
|
187 |
+
logo_width, logo_height = 120, 22
|
188 |
+
logo_padding = 40
|
189 |
+
else:
|
190 |
+
padding_x = 60
|
191 |
+
bottom_padding = 80
|
192 |
+
top_padding = 60
|
193 |
+
logo_width, logo_height = 121, 23 # Novas dimensões: L:121, A:22.75 (arredondado para 23)
|
194 |
+
|
195 |
+
max_width = width - 2 * padding_x
|
196 |
+
|
197 |
+
canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255))
|
198 |
+
|
199 |
+
# Adicionar imagem de fundo se fornecida
|
200 |
+
if image_url:
|
201 |
+
img = download_image_from_url(image_url)
|
202 |
+
filled_img = resize_and_crop_to_fill(img, width, height)
|
203 |
+
canvas.paste(filled_img, (0, 0))
|
204 |
+
|
205 |
+
# Para Instagram: adicionar gradiente e texto
|
206 |
+
if not is_web:
|
207 |
+
# Só aplicar gradiente se o texto for branco
|
208 |
+
if text_color.lower() != "black":
|
209 |
+
gradient_overlay = create_gradient_overlay(width, height, text_position)
|
210 |
+
canvas = Image.alpha_composite(canvas, gradient_overlay)
|
211 |
+
|
212 |
+
if headline:
|
213 |
+
draw = ImageDraw.Draw(canvas)
|
214 |
+
font_path = "fonts/AGaramondPro-Semibold.ttf"
|
215 |
+
line_height_factor = 1.05 # 105% da altura da linha
|
216 |
+
|
217 |
+
try:
|
218 |
+
font, lines, font_size = get_responsive_font_and_lines(
|
219 |
+
headline, font_path, max_width, max_lines=3,
|
220 |
+
max_font_size=80, min_font_size=20
|
221 |
+
)
|
222 |
+
line_height = int(font_size * line_height_factor)
|
223 |
+
|
224 |
+
except Exception as e:
|
225 |
+
raise HTTPException(status_code=500, detail=f"Erro ao processar a fonte: {e}")
|
226 |
+
|
227 |
+
total_text_height = len(lines) * line_height
|
228 |
+
|
229 |
+
# Posicionar texto baseado no parâmetro text_position
|
230 |
+
if text_position.lower() == "bottom":
|
231 |
+
# Posicionar texto 50px acima da logo (que está em Y:1274)
|
232 |
+
text_end_y = 1274 - 50
|
233 |
+
start_y = text_end_y - total_text_height
|
234 |
+
else: # text_position == "top"
|
235 |
+
# Posicionar texto no topo com padding
|
236 |
+
start_y = top_padding
|
237 |
+
|
238 |
+
# Adicionar logo no canto inferior direito (posição fixa)
|
239 |
+
try:
|
240 |
+
logo_path = "recurve.png"
|
241 |
+
logo = Image.open(logo_path).convert("RGBA")
|
242 |
+
logo_resized = logo.resize((logo_width, logo_height))
|
243 |
+
|
244 |
+
# Aplicar opacidade de 42%
|
245 |
+
logo_with_opacity = Image.new("RGBA", logo_resized.size)
|
246 |
+
for x in range(logo_resized.width):
|
247 |
+
for y in range(logo_resized.height):
|
248 |
+
r, g, b, a = logo_resized.getpixel((x, y))
|
249 |
+
new_alpha = int(a * 0.42) # 42% de opacidade
|
250 |
+
logo_with_opacity.putpixel((x, y), (r, g, b, new_alpha))
|
251 |
+
|
252 |
+
# Posição fixa: X:891, Y:1274
|
253 |
+
canvas.paste(logo_with_opacity, (891, 1274), logo_with_opacity)
|
254 |
+
except Exception as e:
|
255 |
+
raise HTTPException(status_code=500, detail=f"Erro ao carregar a logo: {e}")
|
256 |
+
|
257 |
+
# Adiciona texto com a cor especificada
|
258 |
+
for i, line in enumerate(lines):
|
259 |
+
y = start_y + i * line_height
|
260 |
+
draw.text((padding_x, y), line, font=font, fill=text_rgb)
|
261 |
+
|
262 |
+
# Para web: apenas logo no canto inferior direito
|
263 |
+
else:
|
264 |
+
try:
|
265 |
+
logo_path = "recurve.png"
|
266 |
+
logo = Image.open(logo_path).convert("RGBA")
|
267 |
+
logo_resized = logo.resize((logo_width, logo_height))
|
268 |
+
logo_x = width - logo_width - logo_padding
|
269 |
+
logo_y = height - logo_height - logo_padding
|
270 |
+
canvas.paste(logo_resized, (logo_x, logo_y), logo_resized)
|
271 |
+
except Exception as e:
|
272 |
+
raise HTTPException(status_code=500, detail=f"Erro ao carregar a logo: {e}")
|
273 |
+
|
274 |
+
buffer = BytesIO()
|
275 |
+
canvas.convert("RGB").save(buffer, format="PNG")
|
276 |
+
buffer.seek(0)
|
277 |
+
return buffer
|
278 |
+
|
279 |
+
@router.get("/cover/news")
|
280 |
+
def get_news_image(
|
281 |
+
image_url: Optional[str] = Query(None, description="URL da imagem de fundo"),
|
282 |
+
headline: Optional[str] = Query(None, description="Texto do título (opcional para IG, ignorado para web)"),
|
283 |
+
device: str = Query("ig", description="Dispositivo: 'ig' para Instagram (1080x1350) ou 'web' para Web (1280x720)"),
|
284 |
+
text_position: str = Query("bottom", description="Posição do texto: 'top' para topo ou 'bottom' para parte inferior"),
|
285 |
+
text_color: str = Query("white", description="Cor do texto: 'white' (padrão) ou 'black'. Se 'black', remove o gradiente de fundo")
|
286 |
+
):
|
287 |
+
try:
|
288 |
+
buffer = create_canvas(image_url, headline, device, text_position, text_color)
|
289 |
+
return StreamingResponse(buffer, media_type="image/png")
|
290 |
+
except Exception as e:
|
291 |
+
raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}")
|
routers/search.py
ADDED
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, HTTPException, Query
|
2 |
+
from fastapi.responses import JSONResponse
|
3 |
+
import httpx
|
4 |
+
import json
|
5 |
+
import re
|
6 |
+
from urllib.parse import unquote
|
7 |
+
from PIL import Image
|
8 |
+
import io
|
9 |
+
import asyncio
|
10 |
+
import struct
|
11 |
+
from typing import Optional, Tuple
|
12 |
+
|
13 |
+
router = APIRouter()
|
14 |
+
|
15 |
+
@router.get("/search")
|
16 |
+
async def search(
|
17 |
+
q: str = Query(..., description="Termo de pesquisa para imagens"),
|
18 |
+
min_width: int = Query(1200, description="Largura mínima das imagens (padrão: 1200px)")
|
19 |
+
):
|
20 |
+
"""
|
21 |
+
Busca imagens no Google Imagens e retorna uma lista estruturada
|
22 |
+
Agora com filtro de largura mínima
|
23 |
+
"""
|
24 |
+
|
25 |
+
# URL do Google Imagens com parâmetros para imagens grandes
|
26 |
+
google_images_url = "http://www.google.com/search"
|
27 |
+
|
28 |
+
params = {
|
29 |
+
"tbm": "isch", # Google Images
|
30 |
+
"q": q,
|
31 |
+
"start": 0,
|
32 |
+
"sa": "N",
|
33 |
+
"asearch": "arc",
|
34 |
+
"cs": "1",
|
35 |
+
"tbs": "isz:l", # Adiciona filtro para imagens grandes (Large)
|
36 |
+
# Outras opções disponíveis:
|
37 |
+
# "isz:m" = Medium
|
38 |
+
# "isz:i" = Icon
|
39 |
+
# "isz:lt,islt:2mp" = Larger than 2MP
|
40 |
+
# "isz:ex,iszw:1920,iszh:1080" = Exact size
|
41 |
+
"async": f"arc_id:srp_GgSMaOPQOtL_5OUPvbSTOQ_110,ffilt:all,ve_name:MoreResultsContainer,inf:1,_id:arc-srp_GgSMaOPQOtL_5OUPvbSTOQ_110,_pms:s,_fmt:pc"
|
42 |
+
}
|
43 |
+
|
44 |
+
headers = {
|
45 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
46 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
47 |
+
"Accept-Language": "pt-BR,pt;q=0.8,en-US;q=0.5,en;q=0.3",
|
48 |
+
"Accept-Encoding": "gzip, deflate",
|
49 |
+
"Connection": "keep-alive",
|
50 |
+
"Referer": "https://www.google.com/"
|
51 |
+
}
|
52 |
+
|
53 |
+
try:
|
54 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
55 |
+
response = await client.get(google_images_url, params=params, headers=headers)
|
56 |
+
|
57 |
+
if response.status_code != 200:
|
58 |
+
raise HTTPException(status_code=response.status_code, detail="Erro ao buscar no Google Imagens")
|
59 |
+
|
60 |
+
# Extrair dados das imagens do conteúdo retornado
|
61 |
+
images = extract_images_from_response(response.text)
|
62 |
+
|
63 |
+
# Enriquecer com dimensões reais das imagens (otimizado)
|
64 |
+
enriched_images = await enrich_images_with_dimensions_optimized(images)
|
65 |
+
|
66 |
+
# Filtrar apenas imagens que têm dimensões válidas E largura >= min_width
|
67 |
+
valid_images = [
|
68 |
+
img for img in enriched_images
|
69 |
+
if img.get('width') is not None
|
70 |
+
and img.get('height') is not None
|
71 |
+
and img.get('width') >= min_width
|
72 |
+
]
|
73 |
+
|
74 |
+
# Se não temos resultados suficientes, tenta buscar mais com filtros mais agressivos
|
75 |
+
if len(valid_images) < 20:
|
76 |
+
print(f"Poucos resultados com largura >= {min_width}px, buscando mais imagens...")
|
77 |
+
|
78 |
+
# Tenta uma segunda busca com filtro de imagens extra grandes
|
79 |
+
params["tbs"] = "isz:lt,islt:4mp" # Larger than 4MP
|
80 |
+
|
81 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
82 |
+
response2 = await client.get(google_images_url, params=params, headers=headers)
|
83 |
+
|
84 |
+
if response2.status_code == 200:
|
85 |
+
additional_images = extract_images_from_response(response2.text)
|
86 |
+
additional_enriched = await enrich_images_with_dimensions_optimized(additional_images)
|
87 |
+
|
88 |
+
# Combina os resultados e remove duplicatas
|
89 |
+
all_images = enriched_images + additional_enriched
|
90 |
+
seen_urls = set()
|
91 |
+
unique_images = []
|
92 |
+
|
93 |
+
for img in all_images:
|
94 |
+
if (img.get('url') not in seen_urls
|
95 |
+
and img.get('width') is not None
|
96 |
+
and img.get('height') is not None
|
97 |
+
and img.get('width') >= min_width):
|
98 |
+
seen_urls.add(img.get('url'))
|
99 |
+
unique_images.append(img)
|
100 |
+
|
101 |
+
valid_images = unique_images
|
102 |
+
|
103 |
+
# Ordena por largura (maiores primeiro) e limita a 50 resultados
|
104 |
+
valid_images.sort(key=lambda x: x.get('width', 0), reverse=True)
|
105 |
+
final_images = valid_images[:50]
|
106 |
+
|
107 |
+
return JSONResponse(content={
|
108 |
+
"query": q,
|
109 |
+
"min_width_filter": min_width,
|
110 |
+
"total_found": len(final_images),
|
111 |
+
"images": final_images
|
112 |
+
})
|
113 |
+
|
114 |
+
except httpx.TimeoutException:
|
115 |
+
raise HTTPException(status_code=408, detail="Timeout na requisição ao Google")
|
116 |
+
except Exception as e:
|
117 |
+
raise HTTPException(status_code=500, detail=f"Erro ao executar a busca: {str(e)}")
|
118 |
+
|
119 |
+
|
120 |
+
def clean_wikimedia_url(url: str) -> str:
|
121 |
+
"""
|
122 |
+
Remove 'thumb/' das URLs do Wikimedia para obter imagem em resolução original
|
123 |
+
Funciona com URLs que terminam direto no arquivo ou com redimensionamento
|
124 |
+
"""
|
125 |
+
if 'wikimedia.org' in url and '/thumb/' in url:
|
126 |
+
try:
|
127 |
+
# Casos possíveis:
|
128 |
+
# 1. https://upload.wikimedia.org/wikipedia/commons/thumb/7/79/James_Gunn_%2828557194032%29_%28cropped%29.jpg
|
129 |
+
# 2. https://upload.wikimedia.org/wikipedia/commons/thumb/7/79/James_Gunn_%2828557194032%29_%28cropped%29.jpg/220px-James_Gunn_%2828557194032%29_%28cropped%29.jpg
|
130 |
+
# Ambos devem virar: https://upload.wikimedia.org/wikipedia/commons/7/79/James_Gunn_%2828557194032%29_%28cropped%29.jpg
|
131 |
+
|
132 |
+
# Divide a URL na parte /thumb/
|
133 |
+
parts = url.split('/thumb/')
|
134 |
+
if len(parts) == 2:
|
135 |
+
before_thumb = parts[0] # https://upload.wikimedia.org/wikipedia/commons
|
136 |
+
after_thumb = parts[1] # 7/79/James_Gunn_%2828557194032%29_%28cropped%29.jpg ou 7/79/James_Gunn_%2828557194032%29_%28cropped%29.jpg/220px-...
|
137 |
+
|
138 |
+
# Divide o after_thumb por barras
|
139 |
+
path_parts = after_thumb.split('/')
|
140 |
+
|
141 |
+
if len(path_parts) >= 3:
|
142 |
+
# Estrutura: ['7', '79', 'filename.jpg'] ou ['7', '79', 'filename.jpg', 'resized-filename.jpg']
|
143 |
+
# Queremos sempre pegar os 3 primeiros elementos (diretório + nome original)
|
144 |
+
original_path = '/'.join(path_parts[:3]) # '7/79/James_Gunn_%2828557194032%29_%28cropped%29.jpg'
|
145 |
+
cleaned_url = f"{before_thumb}/{original_path}"
|
146 |
+
print(f"URL limpa: {url} -> {cleaned_url}")
|
147 |
+
return cleaned_url
|
148 |
+
elif len(path_parts) == 2:
|
149 |
+
# Caso onde só tem diretório/arquivo: 7/79 (sem o nome do arquivo)
|
150 |
+
# Neste caso, a URL original pode estar malformada, retorna como está
|
151 |
+
print(f"URL do Wikimedia malformada (sem nome do arquivo): {url}")
|
152 |
+
|
153 |
+
except Exception as e:
|
154 |
+
print(f"Erro ao limpar URL do Wikimedia: {e}")
|
155 |
+
|
156 |
+
return url
|
157 |
+
|
158 |
+
|
159 |
+
def extract_images_from_response(response_text: str) -> list:
|
160 |
+
"""
|
161 |
+
Extrai informações das imagens do HTML/JavaScript retornado pelo Google
|
162 |
+
Agora busca mais URLs para garantir resultados com alta resolução
|
163 |
+
"""
|
164 |
+
images = []
|
165 |
+
|
166 |
+
try:
|
167 |
+
# Usar o regex antigo que funcionava para pegar todas as URLs
|
168 |
+
pattern = r'https?:\/\/[^\s"\'<>]+?\.(?:jpg|png|webp|jpeg)\b'
|
169 |
+
image_urls = re.findall(pattern, response_text, re.IGNORECASE)
|
170 |
+
|
171 |
+
# Remove duplicatas mantendo a ordem
|
172 |
+
seen_urls = set()
|
173 |
+
unique_urls = []
|
174 |
+
for url in image_urls:
|
175 |
+
# Limpa a URL imediatamente ao extrair
|
176 |
+
cleaned_url = clean_wikimedia_url(url)
|
177 |
+
if cleaned_url not in seen_urls:
|
178 |
+
seen_urls.add(cleaned_url)
|
179 |
+
unique_urls.append(cleaned_url)
|
180 |
+
|
181 |
+
# Extrai mais URLs inicialmente (150) porque muitas serão filtradas por largura
|
182 |
+
# Isso garante que tenhamos pelo menos 50 resultados válidos com largura >= 1200px
|
183 |
+
for url in unique_urls[:150]:
|
184 |
+
images.append({
|
185 |
+
"url": url,
|
186 |
+
"width": None,
|
187 |
+
"height": None
|
188 |
+
})
|
189 |
+
|
190 |
+
except Exception as e:
|
191 |
+
print(f"Erro na extração: {e}")
|
192 |
+
|
193 |
+
return images
|
194 |
+
|
195 |
+
|
196 |
+
def get_image_size_from_bytes(data: bytes) -> Optional[Tuple[int, int]]:
|
197 |
+
"""
|
198 |
+
Extrai dimensões da imagem usando apenas os primeiros bytes (muito rápido)
|
199 |
+
Suporta JPEG, PNG, GIF, WebP sem usar PIL - versão melhorada
|
200 |
+
"""
|
201 |
+
if len(data) < 24:
|
202 |
+
return None
|
203 |
+
|
204 |
+
try:
|
205 |
+
# JPEG
|
206 |
+
if data[:2] == b'\xff\xd8':
|
207 |
+
i = 2
|
208 |
+
while i < len(data) - 8:
|
209 |
+
if data[i:i+2] == b'\xff\xc0' or data[i:i+2] == b'\xff\xc2':
|
210 |
+
if i + 9 <= len(data):
|
211 |
+
height = struct.unpack('>H', data[i+5:i+7])[0]
|
212 |
+
width = struct.unpack('>H', data[i+7:i+9])[0]
|
213 |
+
if width > 0 and height > 0:
|
214 |
+
return width, height
|
215 |
+
i += 1
|
216 |
+
|
217 |
+
# PNG
|
218 |
+
elif data[:8] == b'\x89PNG\r\n\x1a\n':
|
219 |
+
if len(data) >= 24:
|
220 |
+
width = struct.unpack('>I', data[16:20])[0]
|
221 |
+
height = struct.unpack('>I', data[20:24])[0]
|
222 |
+
if width > 0 and height > 0:
|
223 |
+
return width, height
|
224 |
+
|
225 |
+
# GIF
|
226 |
+
elif data[:6] in (b'GIF87a', b'GIF89a'):
|
227 |
+
if len(data) >= 10:
|
228 |
+
width = struct.unpack('<H', data[6:8])[0]
|
229 |
+
height = struct.unpack('<H', data[8:10])[0]
|
230 |
+
if width > 0 and height > 0:
|
231 |
+
return width, height
|
232 |
+
|
233 |
+
# WebP
|
234 |
+
elif data[:4] == b'RIFF' and len(data) > 12 and data[8:12] == b'WEBP':
|
235 |
+
if len(data) >= 30:
|
236 |
+
if data[12:16] == b'VP8 ':
|
237 |
+
# VP8 format
|
238 |
+
if len(data) >= 30:
|
239 |
+
width = struct.unpack('<H', data[26:28])[0] & 0x3fff
|
240 |
+
height = struct.unpack('<H', data[28:30])[0] & 0x3fff
|
241 |
+
if width > 0 and height > 0:
|
242 |
+
return width, height
|
243 |
+
elif data[12:16] == b'VP8L':
|
244 |
+
# VP8L format
|
245 |
+
if len(data) >= 25:
|
246 |
+
bits = struct.unpack('<I', data[21:25])[0]
|
247 |
+
width = (bits & 0x3fff) + 1
|
248 |
+
height = ((bits >> 14) & 0x3fff) + 1
|
249 |
+
if width > 0 and height > 0:
|
250 |
+
return width, height
|
251 |
+
elif data[12:16] == b'VP8X':
|
252 |
+
# VP8X format (extended)
|
253 |
+
if len(data) >= 30:
|
254 |
+
width = struct.unpack('<I', data[24:27] + b'\x00')[0] + 1
|
255 |
+
height = struct.unpack('<I', data[27:30] + b'\x00')[0] + 1
|
256 |
+
if width > 0 and height > 0:
|
257 |
+
return width, height
|
258 |
+
|
259 |
+
except (struct.error, IndexError) as e:
|
260 |
+
# Se houver erro no parsing, retorna None silenciosamente
|
261 |
+
pass
|
262 |
+
|
263 |
+
return None
|
264 |
+
|
265 |
+
|
266 |
+
async def get_image_dimensions_fast(client: httpx.AsyncClient, url: str) -> Tuple[str, Optional[int], Optional[int]]:
|
267 |
+
"""
|
268 |
+
Obtém dimensões da imagem de forma otimizada
|
269 |
+
A URL já foi limpa na função extract_images_from_response
|
270 |
+
"""
|
271 |
+
try:
|
272 |
+
# Limpa a URL de caracteres escapados e problemáticos
|
273 |
+
clean_url = url.replace('\\u003d', '=').replace('\\u0026', '&').replace('\\\\', '').replace('\\/', '/')
|
274 |
+
|
275 |
+
# Headers otimizados - primeiro tenta com range pequeno
|
276 |
+
headers = {
|
277 |
+
'Range': 'bytes=0-2048', # Aumentado para 2KB para ser mais confiável
|
278 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
279 |
+
'Accept': 'image/*',
|
280 |
+
'Accept-Encoding': 'gzip, deflate',
|
281 |
+
'Connection': 'close'
|
282 |
+
}
|
283 |
+
|
284 |
+
# Timeout bem baixo para ser rápido
|
285 |
+
response = await client.get(clean_url, headers=headers, timeout=5.0)
|
286 |
+
|
287 |
+
if response.status_code in [200, 206]: # 206 = Partial Content (normal com Range)
|
288 |
+
# Tenta primeiro com parsing manual (mais rápido)
|
289 |
+
dimensions = get_image_size_from_bytes(response.content)
|
290 |
+
if dimensions:
|
291 |
+
print(f"Dimensões obtidas via parsing manual para {clean_url}: {dimensions[0]}x{dimensions[1]}")
|
292 |
+
return clean_url, dimensions[0], dimensions[1]
|
293 |
+
|
294 |
+
# Se não conseguiu com parsing manual, tenta baixar mais dados
|
295 |
+
print(f"Parsing manual falhou para {clean_url}, tentando baixar mais dados...")
|
296 |
+
|
297 |
+
# Remove o Range header para baixar mais dados
|
298 |
+
headers_full = {
|
299 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
300 |
+
'Accept': 'image/*',
|
301 |
+
'Accept-Encoding': 'gzip, deflate',
|
302 |
+
'Connection': 'close'
|
303 |
+
}
|
304 |
+
|
305 |
+
# Tenta baixar os primeiros 10KB
|
306 |
+
headers_full['Range'] = 'bytes=0-10240'
|
307 |
+
response_full = await client.get(clean_url, headers=headers_full, timeout=5.0)
|
308 |
+
|
309 |
+
if response_full.status_code in [200, 206]:
|
310 |
+
# Tenta novamente com parsing manual
|
311 |
+
dimensions = get_image_size_from_bytes(response_full.content)
|
312 |
+
if dimensions:
|
313 |
+
print(f"Dimensões obtidas via parsing manual (10KB) para {clean_url}: {dimensions[0]}x{dimensions[1]}")
|
314 |
+
return clean_url, dimensions[0], dimensions[1]
|
315 |
+
|
316 |
+
# Fallback para PIL se necessário
|
317 |
+
try:
|
318 |
+
image = Image.open(io.BytesIO(response_full.content))
|
319 |
+
width, height = image.size
|
320 |
+
print(f"Dimensões obtidas via PIL para {clean_url}: {width}x{height}")
|
321 |
+
return clean_url, width, height
|
322 |
+
except Exception as pil_error:
|
323 |
+
print(f"PIL também falhou para {clean_url}: {pil_error}")
|
324 |
+
|
325 |
+
else:
|
326 |
+
print(f"Erro HTTP {response.status_code} para {clean_url}")
|
327 |
+
|
328 |
+
except Exception as e:
|
329 |
+
print(f"Erro ao obter dimensões para {url}: {e}")
|
330 |
+
|
331 |
+
print(f"Não foi possível obter dimensões para {url}")
|
332 |
+
return url, None, None
|
333 |
+
|
334 |
+
|
335 |
+
async def enrich_images_with_dimensions_optimized(images: list) -> list:
|
336 |
+
"""
|
337 |
+
Versão otimizada para obter dimensões das imagens
|
338 |
+
"""
|
339 |
+
if not images:
|
340 |
+
return []
|
341 |
+
|
342 |
+
# Configurações otimizadas para velocidade
|
343 |
+
connector = httpx.AsyncClient(
|
344 |
+
timeout=httpx.Timeout(3.0), # Timeout bem baixo
|
345 |
+
limits=httpx.Limits(
|
346 |
+
max_keepalive_connections=20,
|
347 |
+
max_connections=30,
|
348 |
+
keepalive_expiry=5.0
|
349 |
+
)
|
350 |
+
# http2=True removido para evitar dependência extra
|
351 |
+
)
|
352 |
+
|
353 |
+
# Semáforo para controlar concorrência (aumentado para 15 para processar mais rápido)
|
354 |
+
semaphore = asyncio.Semaphore(15)
|
355 |
+
|
356 |
+
async def process_image_with_semaphore(image_data):
|
357 |
+
async with semaphore:
|
358 |
+
url, width, height = await get_image_dimensions_fast(connector, image_data["url"])
|
359 |
+
return {
|
360 |
+
"url": url,
|
361 |
+
"width": width,
|
362 |
+
"height": height
|
363 |
+
}
|
364 |
+
|
365 |
+
try:
|
366 |
+
# Processa todas as imagens em paralelo
|
367 |
+
tasks = [process_image_with_semaphore(img) for img in images]
|
368 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
369 |
+
|
370 |
+
# Filtra apenas resultados válidos
|
371 |
+
valid_images = []
|
372 |
+
for result in results:
|
373 |
+
if not isinstance(result, Exception):
|
374 |
+
# Adiciona informação se a URL foi limpa (para debug)
|
375 |
+
if 'wikimedia.org' in result['url'] and '/thumb/' not in result['url']:
|
376 |
+
result['cleaned_wikimedia_url'] = True
|
377 |
+
valid_images.append(result)
|
378 |
+
|
379 |
+
return valid_images
|
380 |
+
|
381 |
+
finally:
|
382 |
+
await connector.aclose()
|
routers/subtitle.py
ADDED
@@ -0,0 +1,497 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Query, HTTPException
|
2 |
+
from moviepy.editor import VideoFileClip
|
3 |
+
import tempfile
|
4 |
+
import requests
|
5 |
+
import os
|
6 |
+
import shutil
|
7 |
+
from groq import Groq
|
8 |
+
from audio_separator.separator import Separator
|
9 |
+
from google import genai
|
10 |
+
from google.genai import types
|
11 |
+
|
12 |
+
router = APIRouter()
|
13 |
+
|
14 |
+
def download_file(url: str, suffix: str) -> str:
|
15 |
+
"""Download genérico para arquivos de áudio e vídeo"""
|
16 |
+
print(f"Tentando baixar arquivo de: {url}")
|
17 |
+
headers = {
|
18 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
19 |
+
'Accept': '*/*',
|
20 |
+
'Accept-Language': 'en-US,en;q=0.5',
|
21 |
+
'Accept-Encoding': 'gzip, deflate',
|
22 |
+
'Connection': 'keep-alive',
|
23 |
+
'Upgrade-Insecure-Requests': '1',
|
24 |
+
}
|
25 |
+
|
26 |
+
try:
|
27 |
+
response = requests.get(url, headers=headers, stream=True, timeout=30)
|
28 |
+
print(f"Status da resposta: {response.status_code}")
|
29 |
+
response.raise_for_status()
|
30 |
+
except requests.exceptions.RequestException as e:
|
31 |
+
print(f"Erro na requisição: {e}")
|
32 |
+
raise HTTPException(status_code=400, detail=f"Não foi possível baixar o arquivo: {str(e)}")
|
33 |
+
|
34 |
+
if response.status_code != 200:
|
35 |
+
raise HTTPException(status_code=400, detail=f"Erro ao baixar arquivo. Status: {response.status_code}")
|
36 |
+
|
37 |
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
38 |
+
try:
|
39 |
+
total_size = 0
|
40 |
+
for chunk in response.iter_content(chunk_size=8192):
|
41 |
+
if chunk:
|
42 |
+
tmp.write(chunk)
|
43 |
+
total_size += len(chunk)
|
44 |
+
tmp.close()
|
45 |
+
print(f"Arquivo baixado com sucesso. Tamanho: {total_size} bytes")
|
46 |
+
return tmp.name
|
47 |
+
except Exception as e:
|
48 |
+
tmp.close()
|
49 |
+
if os.path.exists(tmp.name):
|
50 |
+
os.unlink(tmp.name)
|
51 |
+
print(f"Erro ao salvar arquivo: {e}")
|
52 |
+
raise HTTPException(status_code=400, detail=f"Erro ao salvar arquivo: {str(e)}")
|
53 |
+
|
54 |
+
def extract_audio_from_video(video_path: str) -> str:
|
55 |
+
"""Extrai áudio de um arquivo de vídeo e salva como WAV"""
|
56 |
+
print(f"Extraindo áudio do vídeo: {video_path}")
|
57 |
+
|
58 |
+
audio_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
|
59 |
+
audio_path = audio_tmp.name
|
60 |
+
audio_tmp.close()
|
61 |
+
|
62 |
+
try:
|
63 |
+
video = VideoFileClip(video_path)
|
64 |
+
audio = video.audio
|
65 |
+
audio.write_audiofile(audio_path, verbose=False, logger=None)
|
66 |
+
audio.close()
|
67 |
+
video.close()
|
68 |
+
print(f"Áudio extraído com sucesso: {audio_path}")
|
69 |
+
return audio_path
|
70 |
+
except Exception as e:
|
71 |
+
if os.path.exists(audio_path):
|
72 |
+
os.unlink(audio_path)
|
73 |
+
print(f"Erro ao extrair áudio: {e}")
|
74 |
+
raise HTTPException(status_code=500, detail=f"Erro ao extrair áudio do vídeo: {str(e)}")
|
75 |
+
|
76 |
+
def separate_vocals(audio_path: str) -> str:
|
77 |
+
"""Separa vocais do áudio usando audio-separator com modelo UVR_MDXNET_KARA_2.onnx"""
|
78 |
+
print(f"Iniciando separação de vocais do arquivo: {audio_path}")
|
79 |
+
|
80 |
+
# Criar diretório temporário para saída
|
81 |
+
temp_output_dir = tempfile.mkdtemp(prefix="vocal_separation_")
|
82 |
+
|
83 |
+
try:
|
84 |
+
# Inicializar o separador
|
85 |
+
separator = Separator(output_dir=temp_output_dir)
|
86 |
+
|
87 |
+
# Carregar modelo específico para vocais (UVR_MDXNET_KARA_2.onnx é melhor para vocais)
|
88 |
+
print("Carregando modelo UVR_MDXNET_KARA_2.onnx...")
|
89 |
+
separator.load_model('UVR_MDXNET_KARA_2.onnx')
|
90 |
+
|
91 |
+
# Processar arquivo
|
92 |
+
print("Processando separação de vocais...")
|
93 |
+
separator.separate(audio_path)
|
94 |
+
|
95 |
+
# Encontrar o arquivo de vocais gerado
|
96 |
+
# O audio-separator geralmente gera arquivos com sufixos específicos
|
97 |
+
base_name = os.path.splitext(os.path.basename(audio_path))[0]
|
98 |
+
|
99 |
+
# Procurar pelo arquivo de vocais (pode ter diferentes sufixos dependendo do modelo)
|
100 |
+
possible_vocal_files = [
|
101 |
+
f"{base_name}_(Vocals).wav",
|
102 |
+
f"{base_name}_vocals.wav",
|
103 |
+
f"{base_name}_Vocals.wav",
|
104 |
+
f"{base_name}_(Vocal).wav"
|
105 |
+
]
|
106 |
+
|
107 |
+
vocal_file_path = None
|
108 |
+
for possible_file in possible_vocal_files:
|
109 |
+
full_path = os.path.join(temp_output_dir, possible_file)
|
110 |
+
if os.path.exists(full_path):
|
111 |
+
vocal_file_path = full_path
|
112 |
+
break
|
113 |
+
|
114 |
+
# Se não encontrou pelos nomes padrão, procurar qualquer arquivo wav no diretório
|
115 |
+
if not vocal_file_path:
|
116 |
+
wav_files = [f for f in os.listdir(temp_output_dir) if f.endswith('.wav')]
|
117 |
+
if wav_files:
|
118 |
+
# Pegar o primeiro arquivo wav encontrado (assumindo que seja o vocal)
|
119 |
+
vocal_file_path = os.path.join(temp_output_dir, wav_files[0])
|
120 |
+
|
121 |
+
if not vocal_file_path or not os.path.exists(vocal_file_path):
|
122 |
+
raise HTTPException(status_code=500, detail="Arquivo de vocais não foi gerado corretamente")
|
123 |
+
|
124 |
+
# Mover arquivo de vocais para um local temporário permanente
|
125 |
+
vocal_temp = tempfile.NamedTemporaryFile(delete=False, suffix="_vocals.wav")
|
126 |
+
vocal_final_path = vocal_temp.name
|
127 |
+
vocal_temp.close()
|
128 |
+
|
129 |
+
shutil.copy2(vocal_file_path, vocal_final_path)
|
130 |
+
print(f"Vocais separados com sucesso: {vocal_final_path}")
|
131 |
+
|
132 |
+
return vocal_final_path
|
133 |
+
|
134 |
+
except Exception as e:
|
135 |
+
print(f"Erro na separação de vocais: {e}")
|
136 |
+
raise HTTPException(status_code=500, detail=f"Erro ao separar vocais: {str(e)}")
|
137 |
+
|
138 |
+
finally:
|
139 |
+
# Limpar diretório temporário de separação
|
140 |
+
if os.path.exists(temp_output_dir):
|
141 |
+
try:
|
142 |
+
shutil.rmtree(temp_output_dir)
|
143 |
+
print(f"Diretório temporário removido: {temp_output_dir}")
|
144 |
+
except Exception as cleanup_error:
|
145 |
+
print(f"Erro ao remover diretório temporário: {cleanup_error}")
|
146 |
+
|
147 |
+
def format_time(seconds_float: float) -> str:
|
148 |
+
"""Converte segundos para formato de tempo SRT (HH:MM:SS,mmm) - versão melhorada"""
|
149 |
+
# Calcula segundos totais e milissegundos
|
150 |
+
total_seconds = int(seconds_float)
|
151 |
+
milliseconds = int((seconds_float - total_seconds) * 1000)
|
152 |
+
|
153 |
+
# Calcula horas, minutos e segundos restantes
|
154 |
+
hours = total_seconds // 3600
|
155 |
+
minutes = (total_seconds % 3600) // 60
|
156 |
+
seconds = total_seconds % 60
|
157 |
+
|
158 |
+
return f"{hours:02}:{minutes:02}:{seconds:02},{milliseconds:03}"
|
159 |
+
|
160 |
+
def json_to_srt(segments_data) -> str:
|
161 |
+
"""
|
162 |
+
Converte dados de segmentos para formato SRT
|
163 |
+
"""
|
164 |
+
if not segments_data:
|
165 |
+
return ""
|
166 |
+
|
167 |
+
srt_lines = []
|
168 |
+
|
169 |
+
for segment in segments_data:
|
170 |
+
segment_id = segment.get('id', 0) + 1
|
171 |
+
start_time = format_time(segment.get('start', 0.0))
|
172 |
+
end_time = format_time(segment.get('end', 0.0))
|
173 |
+
text = segment.get('text', '').strip()
|
174 |
+
|
175 |
+
if text: # Só adiciona se há texto
|
176 |
+
srt_lines.append(f"{segment_id}")
|
177 |
+
srt_lines.append(f"{start_time} --> {end_time}")
|
178 |
+
srt_lines.append(text)
|
179 |
+
srt_lines.append("") # Linha em branco
|
180 |
+
|
181 |
+
return '\n'.join(srt_lines)
|
182 |
+
|
183 |
+
def convert_to_srt(transcription_data) -> str:
|
184 |
+
"""
|
185 |
+
Função para conversão usando apenas segments
|
186 |
+
"""
|
187 |
+
if hasattr(transcription_data, 'segments') and transcription_data.segments:
|
188 |
+
return json_to_srt(transcription_data.segments)
|
189 |
+
else:
|
190 |
+
return ""
|
191 |
+
|
192 |
+
def translate_subtitle_internal(content: str) -> str:
|
193 |
+
"""
|
194 |
+
Função interna para traduzir legendas usando Gemini
|
195 |
+
Baseada na lógica do inference_sub.py
|
196 |
+
"""
|
197 |
+
try:
|
198 |
+
print("Iniciando tradução da legenda...")
|
199 |
+
|
200 |
+
api_key = os.environ.get("GEMINI_API_KEY")
|
201 |
+
if not api_key:
|
202 |
+
raise HTTPException(status_code=500, detail="GEMINI_API_KEY não configurada")
|
203 |
+
|
204 |
+
client = genai.Client(api_key=api_key)
|
205 |
+
model = "gemini-2.5-pro"
|
206 |
+
|
207 |
+
# Instruções do sistema aprimoradas
|
208 |
+
SYSTEM_INSTRUCTIONS = """
|
209 |
+
Você é um tradutor profissional de legendas especializado em tradução do inglês para o português brasileiro.
|
210 |
+
Sua função é traduzir legendas mantendo a formatação SRT original intacta e seguindo os padrões da Netflix.
|
211 |
+
REGRAS FUNDAMENTAIS:
|
212 |
+
1. NUNCA altere os timestamps (00:00:00,000 --> 00:00:00,000)
|
213 |
+
2. NUNCA altere os números das legendas (1, 2, 3, etc.)
|
214 |
+
3. Mantenha a formatação SRT exata: número, timestamp, texto traduzido, linha em branco
|
215 |
+
4. Traduza APENAS o texto das falas
|
216 |
+
PADRÕES DE TRADUÇÃO:
|
217 |
+
- Tradução natural para português brasileiro
|
218 |
+
- Mantenha o tom e registro da fala original (formal/informal, gírias, etc.)
|
219 |
+
- Preserve nomes próprios, lugares e marcas
|
220 |
+
- Adapte expressões idiomáticas para equivalentes em português quando necessário
|
221 |
+
- Use contrações naturais do português brasileiro (você → cê, para → pra, quando apropriado)
|
222 |
+
FORMATAÇÃO NETFLIX:
|
223 |
+
- Máximo de 2 linhas por legenda
|
224 |
+
- Máximo de 42 caracteres por linha (incluindo espaços)
|
225 |
+
- Use quebra de linha quando o texto for muito longo
|
226 |
+
- Prefira quebras em pontos naturais da fala (após vírgulas, conjunções, etc.)
|
227 |
+
- Centralize o texto quando possível
|
228 |
+
PONTUAÇÃO E ESTILO:
|
229 |
+
- Use pontuação adequada em português
|
230 |
+
- Mantenha reticências (...) para hesitações ou falas interrompidas
|
231 |
+
- Use travessão (–) para diálogos quando necessário
|
232 |
+
- Evite pontos finais desnecessários em falas curtas
|
233 |
+
Sempre retorne APENAS o conteúdo das legendas traduzidas, mantendo a formatação SRT original.
|
234 |
+
"""
|
235 |
+
|
236 |
+
# Primeiro exemplo
|
237 |
+
EXAMPLE_INPUT_1 = """1
|
238 |
+
00:00:00,000 --> 00:00:03,500
|
239 |
+
You could argue he'd done it to curry favor with the guards.
|
240 |
+
2
|
241 |
+
00:00:04,379 --> 00:00:07,299
|
242 |
+
Or maybe make a few friends among us Khans.
|
243 |
+
3
|
244 |
+
00:00:08,720 --> 00:00:12,199
|
245 |
+
Me, I think he did it just to feel normal again.
|
246 |
+
4
|
247 |
+
00:00:13,179 --> 00:00:14,740
|
248 |
+
If only for a short while."""
|
249 |
+
|
250 |
+
EXAMPLE_OUTPUT_1 = """1
|
251 |
+
00:00:00,000 --> 00:00:03,500
|
252 |
+
Você pode dizer que ele fez isso
|
253 |
+
para agradar os guardas.
|
254 |
+
2
|
255 |
+
00:00:04,379 --> 00:00:07,299
|
256 |
+
Ou talvez para fazer alguns amigos
|
257 |
+
entre nós, os Khans.
|
258 |
+
3
|
259 |
+
00:00:08,720 --> 00:00:12,199
|
260 |
+
Eu acho que ele fez isso só para se sentir
|
261 |
+
normal de novo.
|
262 |
+
4
|
263 |
+
00:00:13,179 --> 00:00:14,740
|
264 |
+
Mesmo que só por um tempo."""
|
265 |
+
|
266 |
+
# Segundo exemplo
|
267 |
+
EXAMPLE_INPUT_2 = """1
|
268 |
+
00:00:15,420 --> 00:00:18,890
|
269 |
+
I'm not saying you're wrong, but have you considered the alternatives?
|
270 |
+
2
|
271 |
+
00:00:19,234 --> 00:00:21,567
|
272 |
+
What if we just... I don't know... talked to him?
|
273 |
+
3
|
274 |
+
00:00:22,890 --> 00:00:26,234
|
275 |
+
Listen, Jack, this isn't some Hollywood movie where everything works out.
|
276 |
+
4
|
277 |
+
00:00:27,123 --> 00:00:29,456
|
278 |
+
Sometimes you gotta make the hard choices."""
|
279 |
+
|
280 |
+
EXAMPLE_OUTPUT_2 = """1
|
281 |
+
00:00:15,420 --> 00:00:18,890
|
282 |
+
Não tô dizendo que você tá errado, mas
|
283 |
+
já pensou nas alternativas?
|
284 |
+
2
|
285 |
+
00:00:19,234 --> 00:00:21,567
|
286 |
+
E se a gente só... sei lá...
|
287 |
+
conversasse com ele?
|
288 |
+
3
|
289 |
+
00:00:22,890 --> 00:00:26,234
|
290 |
+
Escuta, Jack, isso não é um filme de
|
291 |
+
Hollywood onde tudo dá certo.
|
292 |
+
4
|
293 |
+
00:00:27,123 --> 00:00:29,456
|
294 |
+
Às vezes você tem que fazer
|
295 |
+
as escolhas difíceis."""
|
296 |
+
|
297 |
+
# Terceiro exemplo com diálogos
|
298 |
+
EXAMPLE_INPUT_3 = """1
|
299 |
+
00:00:30,789 --> 00:00:32,456
|
300 |
+
- Hey, what's up?
|
301 |
+
- Not much, just chilling.
|
302 |
+
2
|
303 |
+
00:00:33,567 --> 00:00:36,123
|
304 |
+
Did you see that new Netflix show everyone's talking about?
|
305 |
+
3
|
306 |
+
00:00:37,234 --> 00:00:40,789
|
307 |
+
Yeah, it's incredible! The cinematography is absolutely stunning.
|
308 |
+
4
|
309 |
+
00:00:41,890 --> 00:00:44,567
|
310 |
+
I can't believe they canceled it after just one season though."""
|
311 |
+
|
312 |
+
EXAMPLE_OUTPUT_3 = """1
|
313 |
+
00:00:30,789 --> 00:00:32,456
|
314 |
+
– E aí, tudo bem?
|
315 |
+
– De boa, só relaxando.
|
316 |
+
2
|
317 |
+
00:00:33,567 --> 00:00:36,123
|
318 |
+
Você viu aquela série nova da Netflix
|
319 |
+
que todo mundo tá falando?
|
320 |
+
3
|
321 |
+
00:00:37,234 --> 00:00:40,789
|
322 |
+
Vi, é incrível! A cinematografia
|
323 |
+
é absolutamente deslumbrante.
|
324 |
+
4
|
325 |
+
00:00:41,890 --> 00:00:44,567
|
326 |
+
Não acredito que cancelaram depois
|
327 |
+
de só uma temporada."""
|
328 |
+
|
329 |
+
# Estrutura de conversação correta com múltiplos exemplos
|
330 |
+
contents = [
|
331 |
+
# Primeiro exemplo: usuário envia legenda
|
332 |
+
types.Content(
|
333 |
+
role="user",
|
334 |
+
parts=[
|
335 |
+
types.Part.from_text(text=EXAMPLE_INPUT_1)
|
336 |
+
]
|
337 |
+
),
|
338 |
+
# Primeiro exemplo: modelo responde com tradução
|
339 |
+
types.Content(
|
340 |
+
role="model",
|
341 |
+
parts=[
|
342 |
+
types.Part.from_text(text=EXAMPLE_OUTPUT_1)
|
343 |
+
]
|
344 |
+
),
|
345 |
+
# Segundo exemplo: usuário envia outra legenda
|
346 |
+
types.Content(
|
347 |
+
role="user",
|
348 |
+
parts=[
|
349 |
+
types.Part.from_text(text=EXAMPLE_INPUT_2)
|
350 |
+
]
|
351 |
+
),
|
352 |
+
# Segundo exemplo: modelo responde com tradução
|
353 |
+
types.Content(
|
354 |
+
role="model",
|
355 |
+
parts=[
|
356 |
+
types.Part.from_text(text=EXAMPLE_OUTPUT_2)
|
357 |
+
]
|
358 |
+
),
|
359 |
+
# Terceiro exemplo: usuário envia legenda com diálogos
|
360 |
+
types.Content(
|
361 |
+
role="user",
|
362 |
+
parts=[
|
363 |
+
types.Part.from_text(text=EXAMPLE_INPUT_3)
|
364 |
+
]
|
365 |
+
),
|
366 |
+
# Terceiro exemplo: modelo responde com tradução
|
367 |
+
types.Content(
|
368 |
+
role="model",
|
369 |
+
parts=[
|
370 |
+
types.Part.from_text(text=EXAMPLE_OUTPUT_3)
|
371 |
+
]
|
372 |
+
),
|
373 |
+
# Agora o usuário envia a legenda real para ser traduzida
|
374 |
+
types.Content(
|
375 |
+
role="user",
|
376 |
+
parts=[
|
377 |
+
types.Part.from_text(text=content)
|
378 |
+
]
|
379 |
+
)
|
380 |
+
]
|
381 |
+
|
382 |
+
config = types.GenerateContentConfig(
|
383 |
+
system_instruction=SYSTEM_INSTRUCTIONS,
|
384 |
+
response_mime_type="text/plain",
|
385 |
+
max_output_tokens=4096,
|
386 |
+
temperature=0.3, # Menos criatividade, mais precisão na tradução
|
387 |
+
)
|
388 |
+
|
389 |
+
response_text = ""
|
390 |
+
for chunk in client.models.generate_content_stream(
|
391 |
+
model=model,
|
392 |
+
contents=contents,
|
393 |
+
config=config
|
394 |
+
):
|
395 |
+
if chunk.text:
|
396 |
+
response_text += chunk.text
|
397 |
+
|
398 |
+
translated_content = response_text.strip()
|
399 |
+
print("Tradução concluída com sucesso")
|
400 |
+
return translated_content
|
401 |
+
|
402 |
+
except Exception as e:
|
403 |
+
print(f"Erro na tradução interna: {e}")
|
404 |
+
# Retorna o conteúdo original se a tradução falhar
|
405 |
+
return content
|
406 |
+
|
407 |
+
@router.get("/subtitle/generate-srt")
|
408 |
+
def generate_srt_subtitle(
|
409 |
+
url: str = Query(..., description="URL do arquivo de áudio (.wav) ou vídeo")
|
410 |
+
):
|
411 |
+
"""
|
412 |
+
Gera legenda em formato SRT a partir de arquivo de áudio ou vídeo
|
413 |
+
- Se for .wav: separa vocais e transcreve
|
414 |
+
- Se for vídeo: extrai áudio, separa vocais e transcreve
|
415 |
+
- Usa modelo UVR_MDXNET_KARA_2.onnx para separação de vocais
|
416 |
+
- Usa segmentação natural do Whisper (segments)
|
417 |
+
- Detecção automática de idioma
|
418 |
+
- Tradução automática sempre ativada
|
419 |
+
"""
|
420 |
+
local_file = None
|
421 |
+
audio_file = None
|
422 |
+
vocal_file = None
|
423 |
+
|
424 |
+
try:
|
425 |
+
# Determinar tipo de arquivo pela URL
|
426 |
+
url_lower = url.lower()
|
427 |
+
is_audio = url_lower.endswith('.wav')
|
428 |
+
is_video = any(url_lower.endswith(ext) for ext in ['.mp4', '.avi', '.mov', '.mkv', '.webm'])
|
429 |
+
|
430 |
+
if not (is_audio or is_video):
|
431 |
+
raise HTTPException(
|
432 |
+
status_code=400,
|
433 |
+
detail="URL deve ser de um arquivo de áudio (.wav) ou vídeo"
|
434 |
+
)
|
435 |
+
|
436 |
+
if is_audio:
|
437 |
+
local_file = download_file(url, ".wav")
|
438 |
+
audio_file = local_file
|
439 |
+
else:
|
440 |
+
local_file = download_file(url, ".mp4")
|
441 |
+
audio_file = extract_audio_from_video(local_file)
|
442 |
+
|
443 |
+
# Separar vocais do áudio
|
444 |
+
vocal_file = separate_vocals(audio_file)
|
445 |
+
|
446 |
+
# Transcrição com configurações fixas otimizadas
|
447 |
+
api_key = os.getenv("GROQ_API")
|
448 |
+
if not api_key:
|
449 |
+
raise HTTPException(status_code=500, detail="GROQ_API key não configurada")
|
450 |
+
|
451 |
+
client = Groq(api_key=api_key)
|
452 |
+
|
453 |
+
print(f"Iniciando transcrição com modelo: whisper-large-v3")
|
454 |
+
with open(vocal_file, "rb") as file:
|
455 |
+
transcription_params = {
|
456 |
+
"file": (os.path.basename(vocal_file), file.read()),
|
457 |
+
"model": "whisper-large-v3",
|
458 |
+
"response_format": "verbose_json",
|
459 |
+
"timestamp_granularities": ["segment"],
|
460 |
+
"temperature": 0.0,
|
461 |
+
# language é automaticamente detectado (não enviado)
|
462 |
+
}
|
463 |
+
|
464 |
+
transcription = client.audio.transcriptions.create(**transcription_params)
|
465 |
+
|
466 |
+
# Converter para SRT usando segments
|
467 |
+
srt_content_original = convert_to_srt(transcription)
|
468 |
+
|
469 |
+
# Traduzir sempre
|
470 |
+
srt_content = translate_subtitle_internal(srt_content_original) if srt_content_original else None
|
471 |
+
|
472 |
+
return {
|
473 |
+
"srt": srt_content,
|
474 |
+
"duration": getattr(transcription, 'duration', 0),
|
475 |
+
"language": getattr(transcription, 'language', 'unknown'),
|
476 |
+
"model_used": "whisper-large-v3",
|
477 |
+
"processing_method": "segments",
|
478 |
+
"vocal_separation": "UVR_MDXNET_KARA_2.onnx",
|
479 |
+
"translation_applied": True,
|
480 |
+
"segment_count": len(transcription.segments) if hasattr(transcription, 'segments') and transcription.segments else 0,
|
481 |
+
"subtitle_count": len([line for line in srt_content.split('\n') if line.strip().isdigit()]) if srt_content else 0
|
482 |
+
}
|
483 |
+
|
484 |
+
except HTTPException:
|
485 |
+
raise
|
486 |
+
except Exception as e:
|
487 |
+
raise HTTPException(status_code=500, detail=f"Erro inesperado: {str(e)}")
|
488 |
+
|
489 |
+
finally:
|
490 |
+
# Limpeza de arquivos temporários
|
491 |
+
for temp_file in [local_file, audio_file, vocal_file]:
|
492 |
+
if temp_file and os.path.exists(temp_file):
|
493 |
+
try:
|
494 |
+
os.unlink(temp_file)
|
495 |
+
print(f"Arquivo temporário removido: {temp_file}")
|
496 |
+
except Exception as cleanup_error:
|
497 |
+
print(f"Erro ao remover arquivo temporário {temp_file}: {cleanup_error}")
|
routers/twitter.py
ADDED
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Query, HTTPException
|
2 |
+
from fastapi.responses import StreamingResponse
|
3 |
+
from PIL import Image, ImageDraw, ImageFont
|
4 |
+
from io import BytesIO
|
5 |
+
import requests
|
6 |
+
import re
|
7 |
+
from html import unescape
|
8 |
+
|
9 |
+
router = APIRouter()
|
10 |
+
|
11 |
+
def fetch_tweet_data(tweet_id: str) -> dict:
|
12 |
+
url = f"https://tweethunter.io/api/thread?tweetId={tweet_id}"
|
13 |
+
headers = {
|
14 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0",
|
15 |
+
"Accept": "application/json",
|
16 |
+
"Referer": "https://tweethunter.io/tweetpik"
|
17 |
+
}
|
18 |
+
try:
|
19 |
+
resp = requests.get(url, headers=headers, timeout=10)
|
20 |
+
resp.raise_for_status()
|
21 |
+
data = resp.json()
|
22 |
+
if not data:
|
23 |
+
raise HTTPException(status_code=404, detail="Tweet não encontrado")
|
24 |
+
return data[0]
|
25 |
+
except Exception as e:
|
26 |
+
raise HTTPException(status_code=400, detail=f"Erro ao buscar tweet: {e}")
|
27 |
+
|
28 |
+
def download_emoji(emoji_url: str) -> Image.Image:
|
29 |
+
try:
|
30 |
+
response = requests.get(emoji_url, timeout=10)
|
31 |
+
response.raise_for_status()
|
32 |
+
emoji_img = Image.open(BytesIO(response.content)).convert("RGBA")
|
33 |
+
return emoji_img.resize((32, 32), Image.Resampling.LANCZOS)
|
34 |
+
except Exception as e:
|
35 |
+
print(f"Erro ao baixar emoji {emoji_url}: {e}")
|
36 |
+
return None
|
37 |
+
|
38 |
+
def clean_tweet_text(text: str) -> str:
|
39 |
+
if not text:
|
40 |
+
return ""
|
41 |
+
|
42 |
+
text = re.sub(r'<a[^>]*>pic\.x\.com/[^<]*</a>', '', text)
|
43 |
+
|
44 |
+
text = re.sub(r'<img[^>]*alt="([^"]*)"[^>]*/?>', r'\1', text)
|
45 |
+
|
46 |
+
text = re.sub(r'<[^>]+>', '', text)
|
47 |
+
|
48 |
+
text = unescape(text)
|
49 |
+
|
50 |
+
text = text.replace('\\n', '\n')
|
51 |
+
|
52 |
+
text = re.sub(r'\n\s*\n', '\n\n', text)
|
53 |
+
text = text.strip()
|
54 |
+
|
55 |
+
return text
|
56 |
+
|
57 |
+
def extract_emojis_from_html(text: str) -> list:
|
58 |
+
emoji_pattern = r'<img[^>]*class="emoji"[^>]*alt="([^"]*)"[^>]*src="([^"]*)"[^>]*/?>'
|
59 |
+
emojis = []
|
60 |
+
|
61 |
+
for match in re.finditer(emoji_pattern, text):
|
62 |
+
emoji_char = match.group(1)
|
63 |
+
emoji_url = match.group(2)
|
64 |
+
start_pos = match.start()
|
65 |
+
end_pos = match.end()
|
66 |
+
emojis.append({
|
67 |
+
'char': emoji_char,
|
68 |
+
'url': emoji_url,
|
69 |
+
'start': start_pos,
|
70 |
+
'end': end_pos
|
71 |
+
})
|
72 |
+
|
73 |
+
return emojis
|
74 |
+
|
75 |
+
def wrap_text_with_emojis(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> list:
|
76 |
+
emojis = extract_emojis_from_html(text)
|
77 |
+
clean_text = clean_tweet_text(text)
|
78 |
+
|
79 |
+
paragraphs = clean_text.split('\n')
|
80 |
+
all_lines = []
|
81 |
+
emoji_positions = []
|
82 |
+
current_char_index = 0
|
83 |
+
|
84 |
+
for paragraph in paragraphs:
|
85 |
+
if not paragraph.strip():
|
86 |
+
all_lines.append({
|
87 |
+
'text': "",
|
88 |
+
'emojis': []
|
89 |
+
})
|
90 |
+
current_char_index += 1
|
91 |
+
continue
|
92 |
+
|
93 |
+
words = paragraph.split()
|
94 |
+
current_line = ""
|
95 |
+
line_emojis = []
|
96 |
+
|
97 |
+
for word in words:
|
98 |
+
test_line = f"{current_line} {word}".strip()
|
99 |
+
|
100 |
+
emoji_count_in_word = 0
|
101 |
+
for emoji in emojis:
|
102 |
+
if emoji['char'] in word:
|
103 |
+
emoji_count_in_word += len(emoji['char'])
|
104 |
+
|
105 |
+
text_width = draw.textlength(test_line, font=font)
|
106 |
+
emoji_width = emoji_count_in_word * 32
|
107 |
+
total_width = text_width + emoji_width
|
108 |
+
|
109 |
+
if total_width <= max_width:
|
110 |
+
current_line = test_line
|
111 |
+
for emoji in emojis:
|
112 |
+
if emoji['char'] in word:
|
113 |
+
emoji_pos_in_line = len(current_line) - len(word) + word.find(emoji['char'])
|
114 |
+
line_emojis.append({
|
115 |
+
'emoji': emoji,
|
116 |
+
'position': emoji_pos_in_line
|
117 |
+
})
|
118 |
+
else:
|
119 |
+
if current_line:
|
120 |
+
all_lines.append({
|
121 |
+
'text': current_line,
|
122 |
+
'emojis': line_emojis.copy()
|
123 |
+
})
|
124 |
+
current_line = word
|
125 |
+
line_emojis = []
|
126 |
+
for emoji in emojis:
|
127 |
+
if emoji['char'] in word:
|
128 |
+
emoji_pos_in_line = word.find(emoji['char'])
|
129 |
+
line_emojis.append({
|
130 |
+
'emoji': emoji,
|
131 |
+
'position': emoji_pos_in_line
|
132 |
+
})
|
133 |
+
|
134 |
+
if current_line:
|
135 |
+
all_lines.append({
|
136 |
+
'text': current_line,
|
137 |
+
'emojis': line_emojis.copy()
|
138 |
+
})
|
139 |
+
|
140 |
+
current_char_index += len(paragraph) + 1
|
141 |
+
|
142 |
+
return all_lines
|
143 |
+
|
144 |
+
def wrap_text_with_newlines(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> list[str]:
|
145 |
+
paragraphs = text.split('\n')
|
146 |
+
all_lines = []
|
147 |
+
|
148 |
+
for paragraph in paragraphs:
|
149 |
+
if not paragraph.strip():
|
150 |
+
all_lines.append("")
|
151 |
+
continue
|
152 |
+
|
153 |
+
words = paragraph.split()
|
154 |
+
current_line = ""
|
155 |
+
|
156 |
+
for word in words:
|
157 |
+
test_line = f"{current_line} {word}".strip()
|
158 |
+
if draw.textlength(test_line, font=font) <= max_width:
|
159 |
+
current_line = test_line
|
160 |
+
else:
|
161 |
+
if current_line:
|
162 |
+
all_lines.append(current_line)
|
163 |
+
current_line = word
|
164 |
+
|
165 |
+
if current_line:
|
166 |
+
all_lines.append(current_line)
|
167 |
+
|
168 |
+
return all_lines
|
169 |
+
|
170 |
+
def download_and_resize_image(url: str, max_width: int, max_height: int) -> Image.Image:
|
171 |
+
try:
|
172 |
+
response = requests.get(url, timeout=10)
|
173 |
+
response.raise_for_status()
|
174 |
+
|
175 |
+
img = Image.open(BytesIO(response.content)).convert("RGB")
|
176 |
+
|
177 |
+
original_width, original_height = img.size
|
178 |
+
ratio = min(max_width / original_width, max_height / original_height)
|
179 |
+
|
180 |
+
new_width = int(original_width * ratio)
|
181 |
+
new_height = int(original_height * ratio)
|
182 |
+
|
183 |
+
return img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
184 |
+
except Exception as e:
|
185 |
+
print(f"Erro ao baixar imagem {url}: {e}")
|
186 |
+
return None
|
187 |
+
|
188 |
+
def create_verification_badge(draw: ImageDraw.Draw, x: int, y: int, size: int = 24):
|
189 |
+
blue_color = (27, 149, 224)
|
190 |
+
|
191 |
+
draw.ellipse((x, y, x + size, y + size), fill=blue_color)
|
192 |
+
|
193 |
+
check_points = [
|
194 |
+
(x + size * 0.25, y + size * 0.5),
|
195 |
+
(x + size * 0.45, y + size * 0.7),
|
196 |
+
(x + size * 0.75, y + size * 0.3)
|
197 |
+
]
|
198 |
+
|
199 |
+
line_width = max(2, size // 12)
|
200 |
+
for i in range(len(check_points) - 1):
|
201 |
+
draw.line([check_points[i], check_points[i + 1]], fill=(255, 255, 255), width=line_width)
|
202 |
+
|
203 |
+
def format_number(num: int) -> str:
|
204 |
+
if num >= 1000000:
|
205 |
+
return f"{num / 1000000:.1f}M"
|
206 |
+
elif num >= 1000:
|
207 |
+
return f"{num / 1000:.1f}K"
|
208 |
+
else:
|
209 |
+
return str(num)
|
210 |
+
|
211 |
+
def draw_rounded_rectangle(draw: ImageDraw.Draw, bbox: tuple, radius: int, fill: tuple):
|
212 |
+
x1, y1, x2, y2 = bbox
|
213 |
+
|
214 |
+
draw.rectangle((x1 + radius, y1, x2 - radius, y2), fill=fill)
|
215 |
+
draw.rectangle((x1, y1 + radius, x2, y2 - radius), fill=fill)
|
216 |
+
|
217 |
+
draw.pieslice((x1, y1, x1 + 2*radius, y1 + 2*radius), 180, 270, fill=fill)
|
218 |
+
draw.pieslice((x2 - 2*radius, y1, x2, y1 + 2*radius), 270, 360, fill=fill)
|
219 |
+
draw.pieslice((x1, y2 - 2*radius, x1 + 2*radius, y2), 90, 180, fill=fill)
|
220 |
+
draw.pieslice((x2 - 2*radius, y2 - 2*radius, x2, y2), 0, 90, fill=fill)
|
221 |
+
|
222 |
+
def draw_rounded_image(img: Image.Image, photo_img: Image.Image, x: int, y: int, radius: int = 16):
|
223 |
+
mask = Image.new("L", photo_img.size, 0)
|
224 |
+
mask_draw = ImageDraw.Draw(mask)
|
225 |
+
mask_draw.rounded_rectangle((0, 0, photo_img.width, photo_img.height), radius, fill=255)
|
226 |
+
|
227 |
+
rounded_img = Image.new("RGBA", photo_img.size, (0, 0, 0, 0))
|
228 |
+
rounded_img.paste(photo_img, (0, 0))
|
229 |
+
rounded_img.putalpha(mask)
|
230 |
+
|
231 |
+
img.paste(rounded_img, (x, y), rounded_img)
|
232 |
+
|
233 |
+
def create_tweet_image(tweet: dict) -> BytesIO:
|
234 |
+
WIDTH, HEIGHT = 1080, 1350
|
235 |
+
|
236 |
+
OUTER_BG_COLOR = (0, 0, 0)
|
237 |
+
INNER_BG_COLOR = (255, 255, 255)
|
238 |
+
TEXT_COLOR = (2, 6, 23)
|
239 |
+
SECONDARY_COLOR = (100, 116, 139)
|
240 |
+
STATS_COLOR = (110, 118, 125)
|
241 |
+
|
242 |
+
OUTER_PADDING = 64
|
243 |
+
INNER_PADDING = 48
|
244 |
+
BORDER_RADIUS = 32
|
245 |
+
AVATAR_SIZE = 96
|
246 |
+
|
247 |
+
raw_text = tweet.get("textHtml", "")
|
248 |
+
cleaned_text = clean_tweet_text(raw_text)
|
249 |
+
photos = tweet.get("photos", [])
|
250 |
+
videos = tweet.get("videos", [])
|
251 |
+
|
252 |
+
media_url = None
|
253 |
+
if videos and videos[0].get("poster"):
|
254 |
+
media_url = videos[0]["poster"]
|
255 |
+
elif photos:
|
256 |
+
media_url = photos[0]
|
257 |
+
|
258 |
+
has_media = media_url is not None
|
259 |
+
|
260 |
+
base_font_size = 40
|
261 |
+
max_iterations = 10
|
262 |
+
current_iteration = 0
|
263 |
+
|
264 |
+
while current_iteration < max_iterations:
|
265 |
+
try:
|
266 |
+
font_name = ImageFont.truetype("fonts/Chirp Bold.woff", int(base_font_size * 0.9))
|
267 |
+
font_handle = ImageFont.truetype("fonts/Chirp Regular.woff", int(base_font_size * 0.9))
|
268 |
+
font_text = ImageFont.truetype("fonts/Chirp Regular.woff", base_font_size)
|
269 |
+
font_stats_number = ImageFont.truetype("fonts/Chirp Bold.woff", int(base_font_size * 0.9))
|
270 |
+
font_stats_label = ImageFont.truetype("fonts/Chirp Regular.woff", int(base_font_size * 0.9))
|
271 |
+
except:
|
272 |
+
font_name = ImageFont.load_default()
|
273 |
+
font_handle = ImageFont.load_default()
|
274 |
+
font_text = ImageFont.load_default()
|
275 |
+
font_stats_number = ImageFont.load_default()
|
276 |
+
font_stats_label = ImageFont.load_default()
|
277 |
+
|
278 |
+
text_max_width = WIDTH - (2 * OUTER_PADDING) - (2 * INNER_PADDING)
|
279 |
+
|
280 |
+
temp_img = Image.new("RGB", (100, 100))
|
281 |
+
temp_draw = ImageDraw.Draw(temp_img)
|
282 |
+
|
283 |
+
has_emojis = '<img' in raw_text and 'emoji' in raw_text
|
284 |
+
|
285 |
+
if has_emojis:
|
286 |
+
lines = wrap_text_with_emojis(raw_text, font_text, text_max_width - 100, temp_draw)
|
287 |
+
else:
|
288 |
+
text_lines = wrap_text_with_newlines(cleaned_text, font_text, text_max_width, temp_draw)
|
289 |
+
lines = [{'text': line, 'emojis': []} for line in text_lines]
|
290 |
+
|
291 |
+
line_height = int(font_text.size * 1.2)
|
292 |
+
text_height = len(lines) * line_height
|
293 |
+
|
294 |
+
media_height = 0
|
295 |
+
media_margin = 0
|
296 |
+
if has_media:
|
297 |
+
if len(cleaned_text) > 200:
|
298 |
+
media_height = 250
|
299 |
+
elif len(cleaned_text) > 100:
|
300 |
+
media_height = 350
|
301 |
+
else:
|
302 |
+
media_height = 450
|
303 |
+
media_margin = 24
|
304 |
+
|
305 |
+
header_height = AVATAR_SIZE + 16
|
306 |
+
text_margin = 20
|
307 |
+
stats_height = 40
|
308 |
+
stats_margin = 32
|
309 |
+
|
310 |
+
total_content_height = (
|
311 |
+
INNER_PADDING +
|
312 |
+
header_height +
|
313 |
+
text_margin +
|
314 |
+
text_height +
|
315 |
+
(media_margin if has_media else 0) +
|
316 |
+
media_height +
|
317 |
+
(media_margin if has_media else 0) +
|
318 |
+
stats_margin +
|
319 |
+
stats_height +
|
320 |
+
INNER_PADDING
|
321 |
+
)
|
322 |
+
|
323 |
+
max_card_height = HEIGHT - (2 * OUTER_PADDING)
|
324 |
+
|
325 |
+
if total_content_height <= max_card_height or base_font_size <= 24:
|
326 |
+
break
|
327 |
+
|
328 |
+
base_font_size -= 2
|
329 |
+
current_iteration += 1
|
330 |
+
|
331 |
+
card_height = min(total_content_height, HEIGHT - (2 * OUTER_PADDING))
|
332 |
+
card_width = WIDTH - (2 * OUTER_PADDING)
|
333 |
+
|
334 |
+
card_x = OUTER_PADDING
|
335 |
+
card_y = (HEIGHT - card_height) // 2 - 30
|
336 |
+
|
337 |
+
img = Image.new("RGB", (WIDTH, HEIGHT), OUTER_BG_COLOR)
|
338 |
+
draw = ImageDraw.Draw(img)
|
339 |
+
|
340 |
+
draw_rounded_rectangle(
|
341 |
+
draw,
|
342 |
+
(card_x, card_y, card_x + card_width, card_y + card_height),
|
343 |
+
BORDER_RADIUS,
|
344 |
+
INNER_BG_COLOR
|
345 |
+
)
|
346 |
+
|
347 |
+
content_x = card_x + INNER_PADDING
|
348 |
+
current_y = card_y + INNER_PADDING
|
349 |
+
|
350 |
+
avatar_y = current_y
|
351 |
+
try:
|
352 |
+
avatar_resp = requests.get(tweet["avatarUrl"], timeout=10)
|
353 |
+
avatar_img = Image.open(BytesIO(avatar_resp.content)).convert("RGBA")
|
354 |
+
avatar_img = avatar_img.resize((AVATAR_SIZE, AVATAR_SIZE), Image.Resampling.LANCZOS)
|
355 |
+
|
356 |
+
mask = Image.new("L", (AVATAR_SIZE, AVATAR_SIZE), 0)
|
357 |
+
mask_draw = ImageDraw.Draw(mask)
|
358 |
+
mask_draw.ellipse((0, 0, AVATAR_SIZE, AVATAR_SIZE), fill=255)
|
359 |
+
|
360 |
+
img.paste(avatar_img, (content_x, avatar_y), mask)
|
361 |
+
except:
|
362 |
+
draw.ellipse(
|
363 |
+
(content_x, avatar_y, content_x + AVATAR_SIZE, avatar_y + AVATAR_SIZE),
|
364 |
+
fill=(200, 200, 200)
|
365 |
+
)
|
366 |
+
|
367 |
+
user_info_x = content_x + AVATAR_SIZE + 20
|
368 |
+
user_info_y = avatar_y
|
369 |
+
|
370 |
+
name = tweet.get("nameHtml", "Nome Desconhecido")
|
371 |
+
name = clean_tweet_text(name)
|
372 |
+
draw.text((user_info_x, user_info_y), name, font=font_name, fill=TEXT_COLOR)
|
373 |
+
|
374 |
+
verified = tweet.get("verified", False)
|
375 |
+
if verified:
|
376 |
+
name_width = draw.textlength(name, font=font_name)
|
377 |
+
badge_x = user_info_x + name_width + 14
|
378 |
+
badge_y = user_info_y + 6
|
379 |
+
create_verification_badge(draw, badge_x, badge_y, 28)
|
380 |
+
|
381 |
+
handle = tweet.get("handler", "@unknown")
|
382 |
+
if not handle.startswith('@'):
|
383 |
+
handle = f"@{handle}"
|
384 |
+
|
385 |
+
handle_y = user_info_y + 44
|
386 |
+
draw.text((user_info_x, handle_y), handle, font=font_handle, fill=SECONDARY_COLOR)
|
387 |
+
|
388 |
+
current_y = avatar_y + header_height + text_margin
|
389 |
+
|
390 |
+
for line_data in lines:
|
391 |
+
line_text = line_data['text']
|
392 |
+
line_emojis = line_data.get('emojis', [])
|
393 |
+
|
394 |
+
if line_text.strip() or line_emojis:
|
395 |
+
text_x = content_x
|
396 |
+
|
397 |
+
if has_emojis and line_emojis:
|
398 |
+
current_x = text_x
|
399 |
+
text_parts = []
|
400 |
+
last_pos = 0
|
401 |
+
|
402 |
+
sorted_emojis = sorted(line_emojis, key=lambda e: e['position'])
|
403 |
+
|
404 |
+
for emoji_data in sorted_emojis:
|
405 |
+
emoji_pos = emoji_data['position']
|
406 |
+
emoji_info = emoji_data['emoji']
|
407 |
+
|
408 |
+
if emoji_pos > last_pos:
|
409 |
+
text_before = line_text[last_pos:emoji_pos]
|
410 |
+
if text_before:
|
411 |
+
draw.text((current_x, current_y), text_before, font=font_text, fill=TEXT_COLOR)
|
412 |
+
current_x += draw.textlength(text_before, font=font_text)
|
413 |
+
|
414 |
+
emoji_img = download_emoji(emoji_info['url'])
|
415 |
+
if emoji_img:
|
416 |
+
emoji_y = current_y + (line_height - 32) // 2
|
417 |
+
img.paste(emoji_img, (int(current_x), int(emoji_y)), emoji_img)
|
418 |
+
current_x += 32
|
419 |
+
else:
|
420 |
+
draw.text((current_x, current_y), emoji_info['char'], font=font_text, fill=TEXT_COLOR)
|
421 |
+
current_x += draw.textlength(emoji_info['char'], font=font_text)
|
422 |
+
|
423 |
+
last_pos = emoji_pos + len(emoji_info['char'])
|
424 |
+
|
425 |
+
if last_pos < len(line_text):
|
426 |
+
remaining_text = line_text[last_pos:]
|
427 |
+
draw.text((current_x, current_y), remaining_text, font=font_text, fill=TEXT_COLOR)
|
428 |
+
else:
|
429 |
+
draw.text((text_x, current_y), line_text, font=font_text, fill=TEXT_COLOR)
|
430 |
+
|
431 |
+
current_y += line_height
|
432 |
+
|
433 |
+
if has_media:
|
434 |
+
current_y += media_margin
|
435 |
+
media_img = download_and_resize_image(media_url, text_max_width, media_height)
|
436 |
+
|
437 |
+
if media_img:
|
438 |
+
media_x = content_x
|
439 |
+
media_y = current_y
|
440 |
+
|
441 |
+
draw_rounded_image(img, media_img, media_x, media_y, 16)
|
442 |
+
current_y = media_y + media_img.height + media_margin
|
443 |
+
|
444 |
+
current_y += stats_margin
|
445 |
+
stats_y = current_y
|
446 |
+
stats_x = content_x
|
447 |
+
|
448 |
+
retweets = tweet.get("retweets", 0)
|
449 |
+
retweets_text = format_number(retweets)
|
450 |
+
draw.text((stats_x, stats_y), retweets_text, font=font_stats_number, fill=TEXT_COLOR)
|
451 |
+
|
452 |
+
retweets_num_width = draw.textlength(retweets_text, font=font_stats_number)
|
453 |
+
retweets_label_x = stats_x + retweets_num_width + 12
|
454 |
+
draw.text((retweets_label_x, stats_y), "Retweets", font=font_stats_label, fill=STATS_COLOR)
|
455 |
+
|
456 |
+
retweets_label_width = draw.textlength("Retweets", font=font_stats_label)
|
457 |
+
likes_x = retweets_label_x + retweets_label_width + 44
|
458 |
+
|
459 |
+
likes = tweet.get("likes", 0)
|
460 |
+
likes_text = format_number(likes)
|
461 |
+
draw.text((likes_x, stats_y), likes_text, font=font_stats_number, fill=TEXT_COLOR)
|
462 |
+
|
463 |
+
likes_num_width = draw.textlength(likes_text, font=font_stats_number)
|
464 |
+
likes_label_x = likes_x + likes_num_width + 12
|
465 |
+
draw.text((likes_label_x, stats_y), "Likes", font=font_stats_label, fill=STATS_COLOR)
|
466 |
+
|
467 |
+
try:
|
468 |
+
logo_path = "recurve.png"
|
469 |
+
logo = Image.open(logo_path).convert("RGBA")
|
470 |
+
logo_width, logo_height = 121, 23
|
471 |
+
logo_resized = logo.resize((logo_width, logo_height))
|
472 |
+
|
473 |
+
logo_with_opacity = Image.new("RGBA", logo_resized.size)
|
474 |
+
for x in range(logo_resized.width):
|
475 |
+
for y in range(logo_resized.height):
|
476 |
+
r, g, b, a = logo_resized.getpixel((x, y))
|
477 |
+
new_alpha = int(a * 0.42)
|
478 |
+
logo_with_opacity.putpixel((x, y), (r, g, b, new_alpha))
|
479 |
+
|
480 |
+
logo_x = WIDTH - logo_width - 64
|
481 |
+
logo_y = HEIGHT - logo_height - 64
|
482 |
+
img.paste(logo_with_opacity, (logo_x, logo_y), logo_with_opacity)
|
483 |
+
except Exception as e:
|
484 |
+
print(f"Erro ao carregar a logo: {e}")
|
485 |
+
|
486 |
+
buffer = BytesIO()
|
487 |
+
img.save(buffer, format="PNG", quality=95)
|
488 |
+
buffer.seek(0)
|
489 |
+
return buffer
|
490 |
+
|
491 |
+
def extract_tweet_id(tweet_url: str) -> str:
|
492 |
+
match = re.search(r"/status/(\d+)", tweet_url)
|
493 |
+
if not match:
|
494 |
+
raise HTTPException(status_code=400, detail="URL de tweet inválida")
|
495 |
+
return match.group(1)
|
496 |
+
|
497 |
+
@router.get("/tweet/image")
|
498 |
+
def get_tweet_image(tweet_url: str = Query(..., description="URL do tweet")):
|
499 |
+
tweet_id = extract_tweet_id(tweet_url)
|
500 |
+
tweet_data = fetch_tweet_data(tweet_id)
|
501 |
+
img_buffer = create_tweet_image(tweet_data)
|
502 |
+
return StreamingResponse(img_buffer, media_type="image/png")
|
routers/video.py
ADDED
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Query, HTTPException
|
2 |
+
from fastapi.responses import StreamingResponse
|
3 |
+
from moviepy.editor import VideoFileClip, CompositeVideoClip, ColorClip, ImageClip, TextClip
|
4 |
+
from moviepy.video.VideoClip import VideoClip
|
5 |
+
from moviepy.video.fx.all import resize
|
6 |
+
from io import BytesIO
|
7 |
+
import tempfile
|
8 |
+
import requests
|
9 |
+
import os
|
10 |
+
import numpy as np
|
11 |
+
from PIL import Image, ImageDraw, ImageFont
|
12 |
+
import gc
|
13 |
+
import re
|
14 |
+
from typing import List, Tuple, Optional
|
15 |
+
|
16 |
+
router = APIRouter()
|
17 |
+
|
18 |
+
def download_file(url: str, suffix: str = ".mp4") -> str:
|
19 |
+
"""Download genérico para vídeos e arquivos SRT"""
|
20 |
+
print(f"Tentando baixar arquivo de: {url}")
|
21 |
+
headers = {
|
22 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
23 |
+
'Accept': '*/*',
|
24 |
+
'Accept-Language': 'en-US,en;q=0.5',
|
25 |
+
'Accept-Encoding': 'gzip, deflate',
|
26 |
+
'Connection': 'keep-alive',
|
27 |
+
'Upgrade-Insecure-Requests': '1',
|
28 |
+
}
|
29 |
+
|
30 |
+
try:
|
31 |
+
response = requests.get(url, headers=headers, stream=True, timeout=30)
|
32 |
+
print(f"Status da resposta: {response.status_code}")
|
33 |
+
response.raise_for_status()
|
34 |
+
except requests.exceptions.RequestException as e:
|
35 |
+
print(f"Erro na requisição: {e}")
|
36 |
+
raise HTTPException(status_code=400, detail=f"Não foi possível baixar o arquivo: {str(e)}")
|
37 |
+
|
38 |
+
if response.status_code != 200:
|
39 |
+
raise HTTPException(status_code=400, detail=f"Erro ao baixar arquivo. Status: {response.status_code}")
|
40 |
+
|
41 |
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
42 |
+
try:
|
43 |
+
total_size = 0
|
44 |
+
for chunk in response.iter_content(chunk_size=8192):
|
45 |
+
if chunk:
|
46 |
+
tmp.write(chunk)
|
47 |
+
total_size += len(chunk)
|
48 |
+
tmp.close()
|
49 |
+
print(f"Arquivo baixado com sucesso. Tamanho: {total_size} bytes")
|
50 |
+
return tmp.name
|
51 |
+
except Exception as e:
|
52 |
+
tmp.close()
|
53 |
+
if os.path.exists(tmp.name):
|
54 |
+
os.unlink(tmp.name)
|
55 |
+
print(f"Erro ao salvar arquivo: {e}")
|
56 |
+
raise HTTPException(status_code=400, detail=f"Erro ao salvar arquivo: {str(e)}")
|
57 |
+
|
58 |
+
def download_video(video_url: str) -> str:
|
59 |
+
return download_file(video_url, ".mp4")
|
60 |
+
|
61 |
+
def download_srt(srt_url: str) -> str:
|
62 |
+
return download_file(srt_url, ".srt")
|
63 |
+
|
64 |
+
def parse_srt(srt_path: str) -> List[Tuple[float, float, str]]:
|
65 |
+
"""Parse arquivo SRT e retorna lista de tuplas (start_time, end_time, text)"""
|
66 |
+
subtitles = []
|
67 |
+
|
68 |
+
with open(srt_path, 'r', encoding='utf-8') as f:
|
69 |
+
content = f.read()
|
70 |
+
|
71 |
+
# Regex para extrair informações do SRT
|
72 |
+
pattern = r'(\d+)\s*\n(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*\n(.*?)(?=\n\d+\s*\n|\n*$)'
|
73 |
+
matches = re.findall(pattern, content, re.DOTALL)
|
74 |
+
|
75 |
+
for match in matches:
|
76 |
+
start_time_str = match[1]
|
77 |
+
end_time_str = match[2]
|
78 |
+
text = match[3].strip()
|
79 |
+
|
80 |
+
# Converter timestamp para segundos
|
81 |
+
start_time = time_to_seconds(start_time_str)
|
82 |
+
end_time = time_to_seconds(end_time_str)
|
83 |
+
|
84 |
+
subtitles.append((start_time, end_time, text))
|
85 |
+
|
86 |
+
print(f"Parsed {len(subtitles)} subtítulos do arquivo SRT")
|
87 |
+
return subtitles
|
88 |
+
|
89 |
+
def time_to_seconds(time_str: str) -> float:
|
90 |
+
"""Converte timestamp SRT (HH:MM:SS,mmm) para segundos"""
|
91 |
+
time_str = time_str.replace(',', '.')
|
92 |
+
parts = time_str.split(':')
|
93 |
+
hours = int(parts[0])
|
94 |
+
minutes = int(parts[1])
|
95 |
+
seconds = float(parts[2])
|
96 |
+
return hours * 3600 + minutes * 60 + seconds
|
97 |
+
|
98 |
+
def create_rounded_mask(w: int, h: int, radius: int) -> np.ndarray:
|
99 |
+
"""Cria uma máscara numpy com cantos arredondados otimizada"""
|
100 |
+
img = Image.new("L", (w, h), 0)
|
101 |
+
draw = ImageDraw.Draw(img)
|
102 |
+
draw.rounded_rectangle((0, 0, w, h), radius=radius, fill=255)
|
103 |
+
mask = np.array(img, dtype=np.float32) / 255.0
|
104 |
+
return mask
|
105 |
+
|
106 |
+
def create_text_image(text: str, font_path: str, font_size: int, color: str = "white", width: int = 900, background_color: str = None) -> np.ndarray:
|
107 |
+
"""Cria uma imagem com texto usando PIL e retorna array numpy diretamente com quebra de linha"""
|
108 |
+
try:
|
109 |
+
font = ImageFont.truetype(font_path, font_size)
|
110 |
+
except:
|
111 |
+
font = ImageFont.load_default()
|
112 |
+
|
113 |
+
# Função para quebrar texto em múltiplas linhas
|
114 |
+
def wrap_text(text, font, max_width):
|
115 |
+
# Primeiro, dividir por quebras de linha existentes (importantes para SRT)
|
116 |
+
existing_lines = text.split('\n')
|
117 |
+
final_lines = []
|
118 |
+
|
119 |
+
for line in existing_lines:
|
120 |
+
if not line.strip(): # Pular linhas vazias
|
121 |
+
continue
|
122 |
+
|
123 |
+
words = line.split(' ')
|
124 |
+
current_line = []
|
125 |
+
|
126 |
+
for word in words:
|
127 |
+
test_line = ' '.join(current_line + [word])
|
128 |
+
bbox = font.getbbox(test_line)
|
129 |
+
test_width = bbox[2] - bbox[0]
|
130 |
+
|
131 |
+
if test_width <= max_width - 40: # 40px de margem total
|
132 |
+
current_line.append(word)
|
133 |
+
else:
|
134 |
+
if current_line:
|
135 |
+
final_lines.append(' '.join(current_line))
|
136 |
+
current_line = [word]
|
137 |
+
else:
|
138 |
+
final_lines.append(word)
|
139 |
+
|
140 |
+
if current_line:
|
141 |
+
final_lines.append(' '.join(current_line))
|
142 |
+
|
143 |
+
return final_lines
|
144 |
+
|
145 |
+
# Quebrar o texto em linhas
|
146 |
+
lines = wrap_text(text, font, width)
|
147 |
+
|
148 |
+
# Calcular dimensões totais baseadas na altura real da fonte
|
149 |
+
font_metrics = font.getmetrics()
|
150 |
+
ascent, descent = font_metrics
|
151 |
+
line_height = ascent + descent
|
152 |
+
line_spacing = int(line_height * 0.2)
|
153 |
+
total_height = len(lines) * line_height + (len(lines) - 1) * line_spacing
|
154 |
+
|
155 |
+
# Definir padding para o fundo
|
156 |
+
padding_vertical = 16 if background_color else 10
|
157 |
+
padding_horizontal = 24 if background_color else 10
|
158 |
+
|
159 |
+
# Criar imagem com altura ajustada para múltiplas linhas
|
160 |
+
img = Image.new("RGBA", (width, total_height + padding_vertical * 2), (0, 0, 0, 0))
|
161 |
+
draw = ImageDraw.Draw(img)
|
162 |
+
|
163 |
+
# Desenhar fundo se especificado
|
164 |
+
if background_color:
|
165 |
+
# Calcular largura máxima do texto para um fundo mais ajustado
|
166 |
+
max_text_width = 0
|
167 |
+
for line in lines:
|
168 |
+
bbox = font.getbbox(line)
|
169 |
+
line_width = bbox[2] - bbox[0]
|
170 |
+
max_text_width = max(max_text_width, line_width)
|
171 |
+
|
172 |
+
# Calcular dimensões do fundo
|
173 |
+
bg_width = max_text_width + padding_horizontal * 2
|
174 |
+
bg_height = total_height + padding_vertical * 2
|
175 |
+
bg_x = (width - bg_width) // 2
|
176 |
+
bg_y = 0
|
177 |
+
|
178 |
+
# Desenhar fundo com cantos arredondados
|
179 |
+
draw.rounded_rectangle(
|
180 |
+
(bg_x, bg_y, bg_x + bg_width, bg_y + bg_height),
|
181 |
+
radius=6,
|
182 |
+
fill=background_color
|
183 |
+
)
|
184 |
+
|
185 |
+
# Desenhar cada linha centralizada usando baseline correto
|
186 |
+
current_y = padding_vertical
|
187 |
+
for line in lines:
|
188 |
+
bbox = font.getbbox(line)
|
189 |
+
line_width = bbox[2] - bbox[0]
|
190 |
+
line_x = (width - line_width) // 2 # Centralizar cada linha
|
191 |
+
draw.text((line_x, current_y), line, font=font, fill=color)
|
192 |
+
current_y += line_height + line_spacing
|
193 |
+
|
194 |
+
return np.array(img, dtype=np.uint8)
|
195 |
+
|
196 |
+
def create_subtitle_clips(subtitles: List[Tuple[float, float, str]], video_duration: float) -> List[ImageClip]:
|
197 |
+
"""Cria clips de legenda otimizados usando ImageClip"""
|
198 |
+
subtitle_clips = []
|
199 |
+
|
200 |
+
for start_time, end_time, text in subtitles:
|
201 |
+
# Ignorar legendas que ultrapassam a duração do vídeo
|
202 |
+
if start_time >= video_duration:
|
203 |
+
continue
|
204 |
+
|
205 |
+
# Ajustar end_time se necessário
|
206 |
+
if end_time > video_duration:
|
207 |
+
end_time = video_duration
|
208 |
+
|
209 |
+
# Criar imagem da legenda com fonte Medium e fundo escuro
|
210 |
+
subtitle_array = create_text_image(
|
211 |
+
text,
|
212 |
+
"fonts/Montserrat-Medium.ttf", # Fonte Medium para legendas
|
213 |
+
32, # Tamanho para legendas
|
214 |
+
"white",
|
215 |
+
900,
|
216 |
+
"#1A1A1A" # Fundo escuro para legendas
|
217 |
+
)
|
218 |
+
|
219 |
+
# Criar clip de imagem
|
220 |
+
subtitle_clip = ImageClip(subtitle_array, duration=end_time - start_time)
|
221 |
+
subtitle_clip = subtitle_clip.set_start(start_time)
|
222 |
+
|
223 |
+
subtitle_clips.append(subtitle_clip)
|
224 |
+
|
225 |
+
print(f"Criados {len(subtitle_clips)} clips de legenda")
|
226 |
+
return subtitle_clips
|
227 |
+
|
228 |
+
def create_centered_video_on_black_background(
|
229 |
+
video_path: str,
|
230 |
+
text: str = "Season 1, episode 1",
|
231 |
+
srt_path: Optional[str] = None,
|
232 |
+
output_resolution=(1080, 1920),
|
233 |
+
max_height=500,
|
234 |
+
max_width=900
|
235 |
+
) -> BytesIO:
|
236 |
+
print(f"Iniciando processamento do vídeo: {video_path}")
|
237 |
+
|
238 |
+
clip = None
|
239 |
+
background = None
|
240 |
+
text_clip = None
|
241 |
+
centered_clip = None
|
242 |
+
final = None
|
243 |
+
subtitle_clips = []
|
244 |
+
|
245 |
+
try:
|
246 |
+
# Carregar vídeo
|
247 |
+
clip = VideoFileClip(video_path, audio=True, verbose=False)
|
248 |
+
print(f"Vídeo carregado - Dimensões: {clip.w}x{clip.h}, Duração: {clip.duration}s, FPS: {clip.fps}")
|
249 |
+
|
250 |
+
# Redimensionar vídeo para 500px de altura máxima
|
251 |
+
if clip.w != max_width or clip.h > max_height:
|
252 |
+
scale_w = max_width / clip.w
|
253 |
+
scale_h = max_height / clip.h
|
254 |
+
scale = min(scale_w, scale_h)
|
255 |
+
new_width = int(clip.w * scale)
|
256 |
+
new_height = int(clip.h * scale)
|
257 |
+
print(f"Redimensionando para: {new_width}x{new_height} (max_height={max_height})")
|
258 |
+
clip = clip.resize(newsize=(new_width, new_height))
|
259 |
+
|
260 |
+
# Criar fundo preto
|
261 |
+
background = ColorClip(size=output_resolution, color=(0, 0, 0), duration=clip.duration)
|
262 |
+
|
263 |
+
# Criar máscara arredondada baseada no tamanho atual do vídeo
|
264 |
+
print(f"Criando máscara para vídeo: {clip.w}x{clip.h}")
|
265 |
+
mask_array = create_rounded_mask(clip.w, clip.h, radius=80)
|
266 |
+
|
267 |
+
def make_mask_frame(t):
|
268 |
+
return mask_array
|
269 |
+
|
270 |
+
mask_clip = VideoClip(make_mask_frame, ismask=True, duration=clip.duration)
|
271 |
+
clip = clip.set_mask(mask_clip)
|
272 |
+
|
273 |
+
# Criar texto principal
|
274 |
+
text_array = create_text_image(text, "fonts/Montserrat-SemiBold.ttf", 38, "white", 900)
|
275 |
+
text_clip = ImageClip(text_array, duration=clip.duration)
|
276 |
+
|
277 |
+
# Centralizar o vídeo
|
278 |
+
centered_clip = clip.set_position(("center", "center"))
|
279 |
+
|
280 |
+
# Posicionar texto principal (45px de distância do vídeo)
|
281 |
+
video_top = (output_resolution[1] - clip.h) // 2
|
282 |
+
text_y = video_top - 45 - text_clip.h
|
283 |
+
text_clip = text_clip.set_position(("center", text_y))
|
284 |
+
|
285 |
+
# Processar legendas se fornecidas
|
286 |
+
if srt_path:
|
287 |
+
print("Processando legendas SRT...")
|
288 |
+
subtitles = parse_srt(srt_path)
|
289 |
+
subtitle_clips = create_subtitle_clips(subtitles, clip.duration)
|
290 |
+
|
291 |
+
# Posicionar legendas abaixo do vídeo (45px de distância)
|
292 |
+
video_bottom = (output_resolution[1] + clip.h) // 2
|
293 |
+
subtitle_y = video_bottom + 45 # 45px de espaçamento
|
294 |
+
|
295 |
+
# Aplicar posicionamento a cada clip individual
|
296 |
+
for i, subtitle_clip in enumerate(subtitle_clips):
|
297 |
+
subtitle_clips[i] = subtitle_clip.set_position(("center", subtitle_y))
|
298 |
+
|
299 |
+
# Compor todos os elementos
|
300 |
+
all_clips = [background, text_clip, centered_clip] + subtitle_clips
|
301 |
+
final = CompositeVideoClip(all_clips)
|
302 |
+
|
303 |
+
print("Composição finalizada, iniciando renderização...")
|
304 |
+
|
305 |
+
buffer = BytesIO()
|
306 |
+
tmp_output_path = None
|
307 |
+
|
308 |
+
try:
|
309 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp_output:
|
310 |
+
tmp_output_path = tmp_output.name
|
311 |
+
|
312 |
+
print(f"Renderizando para arquivo temporário: {tmp_output_path}")
|
313 |
+
|
314 |
+
final.write_videofile(
|
315 |
+
tmp_output_path,
|
316 |
+
codec="libx264",
|
317 |
+
audio_codec="aac",
|
318 |
+
fps=clip.fps,
|
319 |
+
preset="ultrafast",
|
320 |
+
threads=os.cpu_count(),
|
321 |
+
temp_audiofile="temp-audio.m4a",
|
322 |
+
remove_temp=True,
|
323 |
+
audio=True,
|
324 |
+
logger=None,
|
325 |
+
verbose=False,
|
326 |
+
ffmpeg_params=[
|
327 |
+
"-crf", "23",
|
328 |
+
"-movflags", "+faststart",
|
329 |
+
"-tune", "fastdecode",
|
330 |
+
"-x264opts", "no-scenecut"
|
331 |
+
]
|
332 |
+
)
|
333 |
+
|
334 |
+
print("Renderização concluída, lendo arquivo...")
|
335 |
+
with open(tmp_output_path, "rb") as f:
|
336 |
+
buffer.write(f.read())
|
337 |
+
buffer.seek(0)
|
338 |
+
|
339 |
+
print(f"Vídeo processado com sucesso. Tamanho final: {buffer.getbuffer().nbytes} bytes")
|
340 |
+
|
341 |
+
finally:
|
342 |
+
if tmp_output_path and os.path.exists(tmp_output_path):
|
343 |
+
os.unlink(tmp_output_path)
|
344 |
+
|
345 |
+
except Exception as e:
|
346 |
+
print(f"Erro durante processamento: {e}")
|
347 |
+
raise
|
348 |
+
|
349 |
+
finally:
|
350 |
+
# Limpeza de memória
|
351 |
+
clips_to_close = [clip, background, text_clip, centered_clip, final] + subtitle_clips
|
352 |
+
for c in clips_to_close:
|
353 |
+
if c is not None:
|
354 |
+
try:
|
355 |
+
c.close()
|
356 |
+
except:
|
357 |
+
pass
|
358 |
+
|
359 |
+
gc.collect()
|
360 |
+
|
361 |
+
return buffer
|
362 |
+
|
363 |
+
@router.get("/cover/video")
|
364 |
+
def get_video_with_black_background(
|
365 |
+
video_url: str = Query(..., description="URL do vídeo em .mp4 para centralizar em fundo preto com cantos arredondados"),
|
366 |
+
text: str = Query("Season 1, episode 1", description="Texto a ser exibido acima do vídeo"),
|
367 |
+
srt_url: Optional[str] = Query(None, description="URL do arquivo SRT de legendas (opcional)")
|
368 |
+
):
|
369 |
+
local_video = None
|
370 |
+
local_srt = None
|
371 |
+
|
372 |
+
try:
|
373 |
+
# Baixar vídeo
|
374 |
+
local_video = download_video(video_url)
|
375 |
+
|
376 |
+
# Baixar SRT se fornecido
|
377 |
+
if srt_url:
|
378 |
+
local_srt = download_srt(srt_url)
|
379 |
+
|
380 |
+
# Processar vídeo com altura máxima de 500px
|
381 |
+
video_buffer = create_centered_video_on_black_background(
|
382 |
+
local_video,
|
383 |
+
text,
|
384 |
+
local_srt
|
385 |
+
)
|
386 |
+
|
387 |
+
return StreamingResponse(video_buffer, media_type="video/mp4")
|
388 |
+
|
389 |
+
except Exception as e:
|
390 |
+
raise HTTPException(status_code=500, detail=f"Erro ao processar vídeo: {e}")
|
391 |
+
|
392 |
+
finally:
|
393 |
+
# Limpeza de arquivos temporários
|
394 |
+
for temp_file in [local_video, local_srt]:
|
395 |
+
if temp_file and os.path.exists(temp_file):
|
396 |
+
os.unlink(temp_file)
|