habulaj commited on
Commit
4ffe0a9
·
verified ·
1 Parent(s): 40c2ed4

Upload 13 files

Browse files
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)