Squaad AI commited on
Commit
ac37309
·
1 Parent(s): 8a40ae6

Initial commit from cloned Space

Browse files
.gitattributes CHANGED
@@ -33,3 +33,26 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ Inter-Bold.otf filter=lfs diff=lfs merge=lfs -text
37
+ fonts/Inter-Bold.otf filter=lfs diff=lfs merge=lfs -text
38
+ fonts/Montserrat-Bold.ttf filter=lfs diff=lfs merge=lfs -text
39
+ fonts/Montserrat-SemiBold.ttf filter=lfs diff=lfs merge=lfs -text
40
+ fonts/Montserrat-Medium.ttf filter=lfs diff=lfs merge=lfs -text
41
+ fonts/AGaramondPro-Bold.ttf filter=lfs diff=lfs merge=lfs -text
42
+ fonts/AGaramondPro-BoldItalic.ttf filter=lfs diff=lfs merge=lfs -text
43
+ fonts/AGaramondPro-Italic.ttf filter=lfs diff=lfs merge=lfs -text
44
+ fonts/AGaramondPro-Regular.ttf filter=lfs diff=lfs merge=lfs -text
45
+ fonts/AGaramondPro-Semibold.ttf filter=lfs diff=lfs merge=lfs -text
46
+ fonts/AGaramondPro-SemiboldItalic.ttf filter=lfs diff=lfs merge=lfs -text
47
+ fonts/WorkSans-Italic.ttf filter=lfs diff=lfs merge=lfs -text
48
+ fonts/WorkSans-Regular.ttf filter=lfs diff=lfs merge=lfs -text
49
+ fonts/WorkSans-SemiBold.ttf filter=lfs diff=lfs merge=lfs -text
50
+ fonts/WorkSans-SemiBoldItalic.ttf filter=lfs diff=lfs merge=lfs -text
51
+ arrastar.png filter=lfs diff=lfs merge=lfs -text
52
+ cross.png filter=lfs diff=lfs merge=lfs -text
53
+ fonts/Chirp filter=lfs diff=lfs merge=lfs -text
54
+ Bold.woff filter=lfs diff=lfs merge=lfs -text
55
+ Regular.woff filter=lfs diff=lfs merge=lfs -text
56
+ recurve.png filter=lfs diff=lfs merge=lfs -text
57
+ recurvecuriosity.png filter=lfs diff=lfs merge=lfs -text
58
+ star.png filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9
2
+
3
+ RUN apt-get update && apt-get install -y libgl1
4
+ #
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV PATH="/home/user/.local/bin:$PATH"
8
+
9
+ WORKDIR /app
10
+
11
+ COPY --chown=user ./requirements.txt requirements.txt
12
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
13
+
14
+ COPY --chown=user . /app
15
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: Newapi Clone3
3
- emoji: 🌖
4
- colorFrom: green
5
- colorTo: green
6
  sdk: docker
7
  pinned: false
8
  ---
 
1
  ---
2
+ title: Newapi
3
+ emoji: 👁
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
  ---
app.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from fastapi import FastAPI, Request
3
+ from routers import news
4
+ from routers import curiosity
5
+ from routers import memoriam
6
+ from routers import getnews
7
+ from routers import filter
8
+ from routers import inference
9
+ from routers import video
10
+ from routers import subtitle
11
+ from routers import image
12
+ from routers import analyze
13
+ from routers import search
14
+ from routers import twitter
15
+ from routers import searchterm
16
+ from routers import inference_createposter
17
+ from routers import db
18
+ from routers.db import connect_db, disconnect_db
19
+
20
+ @asynccontextmanager
21
+ async def lifespan(app: FastAPI):
22
+ # Startup
23
+ await connect_db()
24
+ yield
25
+ # Shutdown
26
+ await disconnect_db()
27
+
28
+ # Instancia a aplicação FastAPI
29
+ app = FastAPI(lifespan=lifespan)
30
+
31
+ @app.get("/")
32
+ def greet_json():
33
+ return {"Hello": "World!"}
34
+
35
+ # Inclui as rotas
36
+ app.include_router(news.router)
37
+ app.include_router(curiosity.router)
38
+ app.include_router(memoriam.router)
39
+ app.include_router(getnews.router)
40
+ app.include_router(filter.router)
41
+ app.include_router(inference.router)
42
+ app.include_router(video.router)
43
+ app.include_router(subtitle.router)
44
+ app.include_router(image.router)
45
+ app.include_router(analyze.router)
46
+ app.include_router(search.router)
47
+ app.include_router(twitter.router)
48
+ app.include_router(searchterm.router)
49
+ app.include_router(inference_createposter.router)
50
+ app.include_router(db.router)
arrastar.png ADDED

Git LFS Details

  • SHA256: 6eaa26fb9ff29f7d97227f2e059102fc899e6e68ca1c29d2f0887d545727096f
  • Pointer size: 129 Bytes
  • Size of remote file: 3.49 kB
assets/haarcascade_frontalface_default.xml ADDED
The diff for this file is too large to render. See raw diff
 
cross.png ADDED

Git LFS Details

  • SHA256: cf971492e21b9358783b5f7d49ae8c3092322d0914d2f53d24b57c7a3e502631
  • Pointer size: 128 Bytes
  • Size of remote file: 224 Bytes
fonts/AGaramondPro-Bold.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9966b8104a59c5af0d1ca9f01a5643c40cbb7cc77d8774904612badfb7bf98f1
3
+ size 105764
fonts/AGaramondPro-BoldItalic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1db2dbb1b664b6be8a65a81cf61c7d2dd6102c21949509a3049c0892f99be789
3
+ size 107332
fonts/AGaramondPro-Italic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cdd92bc0fb487d46d98b8db2f17f9530fb1769d0536aab810aedf80158a570f4
3
+ size 137248
fonts/AGaramondPro-Regular.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0c4f462ed1a8dc1f188c527eaf4217657ca85525bbec16689a6183b55857ca54
3
+ size 178372
fonts/AGaramondPro-Semibold.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:49566aaa32789492a3e34a0043bc245e9ddd767486bc38744dfe0ee83e0c558d
3
+ size 140732
fonts/AGaramondPro-SemiboldItalic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a5e22572f19cb5f6857d093754af06c4136748e31288f915eb416d3e876b6dd3
3
+ size 107912
fonts/Chirp Bold.woff ADDED
Binary file (51.3 kB). View file
 
fonts/Chirp Regular.woff ADDED
Binary file (49.4 kB). View file
 
fonts/Inter-Bold.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a1e8d028b7007a080d3b081a636712b98d48eeca67cf24724febd9447521e288
3
+ size 232056
fonts/Montserrat-Bold.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:846d5823e5c909a5aad49efbd71dd5f3320a8640fff86840bf7d529c8d8660a5
3
+ size 335788
fonts/Montserrat-Medium.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7d557ed5f56b95c2be4a712e09c9636c864a5ec49bd124fe4f2973bee128ecfb
3
+ size 330872
fonts/Montserrat-SemiBold.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ef7b80ad18d3afab1c769972452d1e2e44c20ba12a7b1cb1c6d662525c8558ae
3
+ size 333988
fonts/WorkSans-Italic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6b7f7002e0b0c8b261fe878658ef5551e3e59d9f6b609b04efb90dde1e2c1ada
3
+ size 174280
fonts/WorkSans-Regular.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e67985a843df0d3cdee51a3d0f329eb1774a344ad9ff0c9ab923751f1577e2a4
3
+ size 188916
fonts/WorkSans-SemiBold.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9220892ffd913cdb1b4cd874009557b2b1330f14366d363ec99393f5c50e08e1
3
+ size 191016
fonts/WorkSans-SemiBoldItalic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d272da2946c6ced1569e68c000ad89d0d36bdd75f9815adc9665116cf50a0ef0
3
+ size 175504
recurve.png ADDED

Git LFS Details

  • SHA256: 54e196582d686b4014aa634881dcb14115d74759072efa0441942d117dbccbf8
  • Pointer size: 129 Bytes
  • Size of remote file: 1.96 kB
recurvecuriosity.png ADDED

Git LFS Details

  • SHA256: a6eafe776cbe4294e8c276eedb0ebf44bf3cca219f9ec150377e41bac3740b85
  • Pointer size: 129 Bytes
  • Size of remote file: 3.77 kB
requirements.txt ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ better-profanity
4
+ pillow
5
+ numpy
6
+ requests
7
+ dnspython
8
+ opencv-python
9
+ lxml_html_clean
10
+ beautifulsoup4
11
+ google-genai
12
+ httpx[http2]
13
+ aiohttp
14
+ transformers
15
+ peft
16
+ torch
17
+ accelerate
18
+ newspaper3k
19
+ trafilatura
20
+ groq
21
+ moviepy==1.0.3
22
+ pygame
23
+ librosa
24
+ scipy
25
+ soundfile
26
+ noisereduce
27
+ audio_separator
28
+ onnxruntime
29
+ aiofiles
30
+ ujson
31
+ datetime
32
+ pydantic
33
+ sqlalchemy
34
+ databases[mysql]
35
+ asyncmy
routers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ ##
routers/analyze.py ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-clone2.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
+ }
139
+
140
+ # Validação básica da resposta
141
+ required_keys = ["title", "subhead", "content"]
142
+ if all(key in rewritten_data and rewritten_data[key].strip() for key in required_keys):
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 required_keys 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
+ required_keys = ["title", "subhead", "content"]
201
+ if all(key in rewritten_data for key in required_keys):
202
+ log.info("Artigo reescrito com sucesso (HTTP)")
203
+ return {
204
+ "success": True,
205
+ "data": rewritten_data,
206
+ "raw_response": response_text,
207
+ "status_code": response.status,
208
+ "method": "http_call"
209
+ }
210
+ else:
211
+ log.error(f"Resposta HTTP incompleta. Chaves encontradas: {list(rewritten_data.keys())}")
212
+ return {
213
+ "success": False,
214
+ "error": "Resposta incompleta",
215
+ "data": rewritten_data,
216
+ "raw_response": response_text,
217
+ "status_code": response.status,
218
+ "method": "http_call",
219
+ "missing_keys": [key for key in required_keys if key not in rewritten_data]
220
+ }
221
+
222
+ except json.JSONDecodeError as e:
223
+ log.error(f"Erro ao fazer parse do JSON: {str(e)}")
224
+ return {
225
+ "success": False,
226
+ "error": f"JSON inválido: {str(e)}",
227
+ "raw_response": response_text,
228
+ "status_code": response.status,
229
+ "method": "http_call"
230
+ }
231
+ else:
232
+ log.error(f"Erro na API HTTP: {response.status}")
233
+ return {
234
+ "success": False,
235
+ "error": f"HTTP {response.status}",
236
+ "raw_response": response_text,
237
+ "status_code": response.status,
238
+ "method": "http_call"
239
+ }
240
+
241
+ except asyncio.TimeoutError:
242
+ log.error("Timeout na API HTTP")
243
+ return {
244
+ "success": False,
245
+ "error": "Timeout",
246
+ "raw_response": "Timeout occurred",
247
+ "status_code": 0,
248
+ "method": "http_call"
249
+ }
250
+ except Exception as e:
251
+ log.error(f"Erro na API HTTP: {str(e)}")
252
+ return {
253
+ "success": False,
254
+ "error": str(e),
255
+ "raw_response": "Exception occurred",
256
+ "status_code": 0,
257
+ "method": "http_call"
258
+ }
259
+
260
+ async def rewrite_article(content: str) -> Optional[Dict[str, Any]]:
261
+ """Reescreve o artigo - tenta chamada direta primeiro, depois HTTP"""
262
+
263
+ # Tenta chamada direta primeiro
264
+ result = await rewrite_article_direct(content)
265
+
266
+ # Se a chamada direta falhou e não foi um fallback, tenta HTTP
267
+ if not result or (not result.get("success") and result.get("method") == "direct_call"):
268
+ log.info("Chamada direta falhou, tentando API HTTP")
269
+ result = await rewrite_article_http(content)
270
+
271
+ return result
272
+
273
+ async def fetch_brazil_interest_news():
274
+ """Busca uma notícia com brazil_interest=true e title_pt vazio"""
275
+ try:
276
+ session = await get_http_session()
277
+ url = f"{SUPABASE_URL}/rest/v1/news"
278
+ params = {
279
+ "brazil_interest": "eq.true",
280
+ "title_pt": "is.null",
281
+ "limit": "1",
282
+ "order": "created_at.asc"
283
+ }
284
+
285
+ async with session.get(url, headers=SUPABASE_HEADERS, params=params) as response:
286
+ if response.status != 200:
287
+ raise HTTPException(status_code=500, detail="Erro ao buscar notícia")
288
+
289
+ data = await response.json()
290
+ if not data:
291
+ raise HTTPException(status_code=404, detail="Nenhuma notícia com brazil_interest=true e title_pt vazio disponível")
292
+
293
+ return data[0]
294
+ except Exception as e:
295
+ raise HTTPException(status_code=500, detail=f"Erro Supabase: {str(e)}")
296
+
297
+ async def update_news_rewrite(news_id: int, rewritten_data: Dict[str, str]):
298
+ """Atualiza a notícia com os dados reescritos incluindo campos do Instagram"""
299
+ try:
300
+ session = await get_http_session()
301
+ url = f"{SUPABASE_URL}/rest/v1/news"
302
+ params = {"id": f"eq.{news_id}"}
303
+
304
+ payload = {
305
+ "title_pt": rewritten_data.get("title", ""),
306
+ "text_pt": rewritten_data.get("content", ""),
307
+ "subhead_pt": rewritten_data.get("subhead", "")
308
+ }
309
+
310
+ async with session.patch(url, headers=SUPABASE_ROLE_HEADERS, json=payload, params=params) as response:
311
+ if response.status not in [200, 201, 204]:
312
+ response_text = await response.text()
313
+ log.error(f"Erro ao atualizar notícia - Status: {response.status}, Response: {response_text}")
314
+ raise HTTPException(status_code=500, detail=f"Erro ao atualizar notícia - Status: {response.status}")
315
+
316
+ log.info(f"Notícia {news_id} atualizada com sucesso - Status: {response.status}")
317
+
318
+ except Exception as e:
319
+ log.error(f"Erro ao atualizar notícia {news_id}: {str(e)}")
320
+ raise HTTPException(status_code=500, detail=f"Erro ao atualizar: {str(e)}")
321
+
322
+ def fix_wikipedia_image_url(url: str) -> str:
323
+ if not url or not url.startswith('//upload.wikimedia.org'):
324
+ return url
325
+
326
+ if url.startswith('//'):
327
+ url = 'https:' + url
328
+
329
+ url = url.replace('/thumb/', '/')
330
+ parts = url.split('/')
331
+ if len(parts) >= 2:
332
+ filename = parts[-1]
333
+ if 'px-' in filename:
334
+ filename = filename.split('px-', 1)[1]
335
+ base_parts = parts[:-2]
336
+ url = '/'.join(base_parts) + '/' + filename
337
+
338
+ return url
339
+
340
+ def extract_birth_death_years(description: str) -> tuple[Optional[int], Optional[int]]:
341
+ if not description:
342
+ return None, None
343
+
344
+ pattern = r'\((?:born\s+)?(\d{4})(?:[–-](\d{4}))?\)'
345
+ match = re.search(pattern, description)
346
+
347
+ if match:
348
+ birth_year = int(match.group(1))
349
+ death_year = int(match.group(2)) if match.group(2) else None
350
+ if death_year is None:
351
+ death_year = 2025
352
+ return birth_year, death_year
353
+
354
+ return None, None
355
+
356
+ async def fetch_wikipedia_info(entity_name: str) -> Optional[Dict[str, Any]]:
357
+ try:
358
+ session = await get_http_session()
359
+
360
+ url = f"https://en.wikipedia.org/w/rest.php/v1/search/title"
361
+ params = {'q': entity_name, 'limit': 1}
362
+
363
+ async with session.get(url, params=params) as response:
364
+ if response.status != 200:
365
+ return None
366
+
367
+ data = await response.json()
368
+
369
+ if not data.get('pages'):
370
+ return None
371
+
372
+ page = data['pages'][0]
373
+ title = page.get('title', '')
374
+ description = page.get('description', '')
375
+ thumbnail = page.get('thumbnail', {})
376
+
377
+ birth_year, death_year = extract_birth_death_years(description)
378
+
379
+ image_url = thumbnail.get('url', '') if thumbnail else ''
380
+ if image_url:
381
+ image_url = fix_wikipedia_image_url(image_url)
382
+
383
+ return {
384
+ 'title': title,
385
+ 'birth_year': birth_year,
386
+ 'death_year': death_year,
387
+ 'image_url': image_url
388
+ }
389
+
390
+ except Exception as e:
391
+ log.error(f"Erro ao buscar Wikipedia: {str(e)}")
392
+ return None
393
+
394
+ def generate_poster_url(name: str, birth: int, death: int, image_url: str) -> str:
395
+ base_url = "https://habulaj-newapi-clone2.hf.space/cover/memoriam"
396
+ params = f"?image_url={quote(image_url)}&name={quote(name)}&birth={birth}&death={death}"
397
+ return base_url + params
398
+
399
+ def generate_news_poster_url(image_url: str, headline: str) -> str:
400
+ """Gera URL do poster para notícias normais (não morte)"""
401
+ base_url = "https://habulaj-newapi-clone2.hf.space/cover/news"
402
+ params = f"?image_url={quote(image_url)}&headline={quote(headline)}"
403
+ return base_url + params
404
+
405
+ async def generate_poster_analysis(news_data: Dict[str, Any], rewritten_result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
406
+ """Gera análise de poster baseado nos dados da notícia"""
407
+ try:
408
+ result = {}
409
+ image_url = news_data.get("image", "")
410
+
411
+ # Verifica se é morte e gera poster apropriado
412
+ if news_data.get("death_related") is True and news_data.get("entity_name"):
413
+ wikipedia_info = await fetch_wikipedia_info(news_data["entity_name"])
414
+
415
+ if wikipedia_info:
416
+ result["wikipedia_info"] = wikipedia_info
417
+
418
+ # Gera poster de morte apenas se tiver morte confirmada
419
+ if (wikipedia_info.get("death_year") and
420
+ wikipedia_info.get("birth_year")):
421
+
422
+ poster_url = generate_poster_url(
423
+ wikipedia_info["title"],
424
+ wikipedia_info["birth_year"],
425
+ wikipedia_info["death_year"],
426
+ wikipedia_info.get("image_url", image_url)
427
+ )
428
+ result["poster"] = poster_url
429
+
430
+ # Se não for morte, gera poster de notícia normal
431
+ if "poster" not in result and image_url:
432
+ # Usa headline reescrito se disponível, senão usa título original
433
+ headline_to_use = news_data.get("title_en", "") # fallback para título original
434
+ if (rewritten_result and
435
+ rewritten_result.get("success") and
436
+ rewritten_result.get("data") and
437
+ rewritten_result["data"].get("title")):
438
+ headline_to_use = rewritten_result["data"]["title"]
439
+
440
+ news_poster_url = generate_news_poster_url(image_url, headline_to_use)
441
+ result["poster"] = news_poster_url
442
+
443
+ return result
444
+
445
+ except Exception as e:
446
+ log.error(f"Erro ao gerar poster: {str(e)}")
447
+ return {}
448
+
449
+ app = FastAPI(title="News Analyze API")
450
+ router = APIRouter()
451
+
452
+ @router.post("/analyze")
453
+ async def analyze_endpoint():
454
+ # Busca notícia com brazil_interest=true e title_pt vazio
455
+ news_data = await fetch_brazil_interest_news()
456
+
457
+ title_en = news_data.get("title_en", "")
458
+ text_en = news_data.get("text_en", "")
459
+ news_id = news_data.get("id")
460
+
461
+ if not title_en.strip() or not text_en.strip():
462
+ raise HTTPException(status_code=400, detail="Title_en and text_en must not be empty.")
463
+
464
+ # Executa reescrita (tenta direta primeiro, depois HTTP)
465
+ rewritten_result = await rewrite_article(text_en)
466
+
467
+ # Log do resultado completo da reescrita
468
+ log.info(f"Resultado completo da reescrita: {json.dumps(rewritten_result, indent=2)}")
469
+
470
+ # Atualiza no banco de dados se reescrita foi bem-sucedida
471
+ if rewritten_result and rewritten_result.get("success") and rewritten_result.get("data"):
472
+ await update_news_rewrite(news_id, rewritten_result["data"])
473
+
474
+ # Gera análise de poster
475
+ poster_analysis = await generate_poster_analysis(news_data, rewritten_result)
476
+
477
+ # Prepara resultado final
478
+ result = {
479
+ "news_id": news_id,
480
+ "title_en": title_en,
481
+ "text_en": text_en,
482
+ "rewrite_result": rewritten_result,
483
+ "death_related": news_data.get("death_related", False),
484
+ "entity_name": news_data.get("entity_name", ""),
485
+ "entity_type": news_data.get("entity_type", ""),
486
+ "image": news_data.get("image", "")
487
+ }
488
+
489
+ # Adiciona informações do poster se disponíveis
490
+ if poster_analysis:
491
+ result.update(poster_analysis)
492
+
493
+ return result
494
+
495
+ app.include_router(router)
496
+
497
+ @app.on_event("shutdown")
498
+ async def shutdown_event():
499
+ global http_session
500
+ if http_session:
501
+ 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/db.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # db.py
2
+ from fastapi import APIRouter, HTTPException
3
+ from pydantic import BaseModel
4
+ from typing import Optional
5
+ import databases
6
+ import sqlalchemy
7
+ import os
8
+
9
+ # Opção 1: Apenas variável de ambiente (mais seguro)
10
+ DATABASE_URL = os.getenv("DATABASE_URL")
11
+
12
+ # Verificação se a variável existe
13
+ if not DATABASE_URL:
14
+ raise ValueError("DATABASE_URL environment variable is not set")
15
+
16
+ database = databases.Database(DATABASE_URL, min_size=5, max_size=20)
17
+ metadata = sqlalchemy.MetaData()
18
+
19
+ igbio = sqlalchemy.Table(
20
+ "igbio",
21
+ metadata,
22
+ sqlalchemy.Column("url_instagram", sqlalchemy.Text),
23
+ sqlalchemy.Column("url_web", sqlalchemy.Text),
24
+ sqlalchemy.Column("image", sqlalchemy.Text),
25
+ )
26
+
27
+ router = APIRouter()
28
+
29
+ class IGPost(BaseModel):
30
+ url_instagram: Optional[str] = None
31
+ url_web: Optional[str] = None
32
+ image: Optional[str] = None
33
+
34
+ async def connect_db():
35
+ await database.connect()
36
+
37
+ async def disconnect_db():
38
+ await database.disconnect()
39
+
40
+ @router.post("/igbio")
41
+ async def add_igbio(item: IGPost):
42
+ def clean_value(value: Optional[str]) -> Optional[str]:
43
+ if value is None:
44
+ return None
45
+ val = value.strip()
46
+ if val == "" or val.lower() == "null":
47
+ return None
48
+ return val
49
+
50
+ query = igbio.insert().values(
51
+ url_instagram=clean_value(item.url_instagram),
52
+ url_web=clean_value(item.url_web),
53
+ image=clean_value(item.image),
54
+ )
55
+
56
+ try:
57
+ record_id = await database.execute(query)
58
+ return {"success": True, "id": record_id}
59
+ except Exception as e:
60
+ raise HTTPException(status_code=500, detail=f"Erro ao inserir no banco: {e}")
routers/filter.py ADDED
@@ -0,0 +1,1011 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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): True only if the news topic has a clear and direct impact, relevance, or interest for the Brazilian audience. This includes:
258
+
259
+ Events, releases, or announcements happening in Brazil or significant international announcements.
260
+ Content (movies, series, sports, games, music) officially available in Brazil.
261
+ People, teams, companies, brands, or productions that are relevant and recognized by the Brazilian audience.
262
+ International celebrities, athletes, or artists with significant fan bases in Brazil.
263
+
264
+ Do not mark as true if the content is unknown to most of the Brazilian population or if the actors, artists, or productions do not have notable recognition in the country.
265
+
266
+ Examples:
267
+
268
+ "Couple on 'House Hunters' with a 30-year age difference shocks viewers" — TRUE (In Brazil, House Hunters is Em Busca da Casa Perfeita, so it is available)
269
+ "Wild Bill Wichrowski from 'Deadliest Catch' will miss the 21st season after battling prostate cancer" — TRUE (Because Deadliest Catch is known in Brazil as Pesca Mortal)
270
+ "Loni Anderson, star of 'WKRP in Cincinnati,' dies at 79" — FALSE (Few people know her in Brazil, and WKRP in Cincinnati is not available there)
271
+ "The 'forgotten' film in the 'Conjuring' universe: why 'The Curse of La Llorona' is considered the worst of the franchise" — TRUE
272
+ "Rose Byrne collapses: new A24 film described as a 'test of endurance'" — TRUE (Rose Byrne is well-known in Brazil)
273
+ "Star Trek: how to understand the timeline of one of the greatest sci-fi sagas" — TRUE
274
+ "Crisis at Mubi: top filmmakers, including Israelis, demand boycott over ties to military investor" — TRUE (Mubi operates in Brazil)
275
+ "Liam Neeson and Joe Keery face biological terror in the trailer for Cold Storage" — TRUE (Joe Keery is well-known in Brazil for Stranger Things)
276
+ "TIFF 2025: from John Candy to Lucrecia Martel, meet the documentaries of the year" — TRUE (Toronto International Film Festival is one of the most famous independent festivals, so it is considered relevant to Brazil)
277
+ "TIFF 2025: festival announces documentaries with Lucrecia Martel and a production by Barack and Michelle Obama" — TRUE (Toronto International Film Festival is well-known, relevant to Brazil)
278
+ "'Stranger Things' universe expands: animated series and stage play confirmed" — TRUE (Stranger Things is well-known in Brazil)
279
+ "New Park Chan-wook film with stars from 'Squid Game' and 'Landing on Love' will open a film festival" — TRUE (No Other Choice features a famous actor from Squid Game)
280
+ "Francis Ford Coppola hospitalized in Rome, but reassures fans: 'I'm fine'" — TRUE (Francis Coppola is internationally known)
281
+ "Ken Jennings used 'Who Wants to Be a Millionaire?' to provoke a rival, but the scene was cut" — FALSE (This program is not Brazilian; Brazil has its own more popular version)
282
+ "Canelo vs. Crawford: Netflix confirms fight of the century without pay-per-view cost" — TRUE (Even though they are not Brazilian, fights usually attract worldwide interest)
283
+
284
+ breaking_news (true | false): The content is urgent or part of a recent and unfolding event.
285
+ audience_age_rating ("L" | 10 | 12 | 14 | 16 | 18): Content rating based on Brazilian standards.
286
+ regional_focus ("global" | "americas" | "europe" | "asia" | "africa" | "middle_east" | "oceania"): The main geographic region the news relates to.
287
+ country_focus (ISO 3166-1 alpha-2 code like "br", "us", "fr", "jp" or null): The specific country the news is about, if applicable.
288
+ ideological_alignment ("left" | "center-left" | "center" | "center-right" | "right" | "apolitical"): The perceived political bias of the article.
289
+ entity_type ("movie" | "series" | "event" | "person" | "place" | "other"): The type of main subject mentioned in the news.
290
+ entity_name (string): The name of the person, title, event, or topic the article is primarily about.
291
+ duplication (true | false): Whether the current news is a duplicate or highly similar to any of the previously published news titles (Last titles).
292
+ """
293
+
294
+ # Formata os últimos títulos para incluir no prompt - aumentado para 25 títulos
295
+ last_titles_formatted = "\n- ".join(last_titles[:25]) if last_titles else "No previous titles available"
296
+
297
+ # Primeiro exemplo - SÉRIE HBO RENOVADA
298
+ EXAMPLE_INPUT_1 = f"""Title: 'The Gilded Age' Renewed for Season 4 at HBO — Everything We Know So Far
299
+ 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
300
+ Last titles:
301
+ - 'Quarteto Fantástico: Primeiros Passos' dispara para arrecadar US$ 118 milhões nas bilheterias dos EUA e US$ 218 milhões globalmente
302
+ - Bilheteria: 'Quarteto Fantástico: Primeiros Passos' sobe para US$ 218 milhões globalmente, 'Superman' e 'F1' ultrapassam US$ 500 milhões
303
+ - Reboot de 'Quarteto Fantástico' da Marvel ultrapassa US$ 200 milhões globalmente"""
304
+
305
+ EXAMPLE_OUTPUT_1 = """{
306
+ "death_related":false,
307
+ "political_related":false,
308
+ "woke_related":false,
309
+ "spoilers":false,
310
+ "sensitive_theme":false,
311
+ "contains_video":false,
312
+ "is_news_content":true,
313
+ "relevance":"low",
314
+ "brazil_interest":true,
315
+ "breaking_news":true,
316
+ "audience_age_rating":14,
317
+ "regional_focus":"americas",
318
+ "country_focus":"us",
319
+ "ideological_alignment":"apolitical",
320
+ "entity_type":"series",
321
+ "entity_name":"The Gilded Age",
322
+ "duplication":false
323
+ }"""
324
+
325
+ # Segundo exemplo - SEQUÊNCIA DE FILME
326
+ EXAMPLE_INPUT_2 = f"""Title: 'My Best Friend's Wedding' Sequel in the Works: 'Materialists,' 'Past Lives' Director Celine Song to Write Screenplay
327
+ 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.
328
+ Last titles:
329
+ - Sequência de "The Batman" ganha data de lançamento oficial da Warner Bros
330
+ - Sequência de "The Batman" de Robert Pattinson tem data oficial de lançamento para 2026
331
+ - Warner Bros. define data de lançamento da sequência de "The Batman" para 2026
332
+ - Sequência de 'O Casamento do Meu Melhor Amigo' terá roteiro da diretora de 'Vidas Passadas'"""
333
+
334
+ EXAMPLE_OUTPUT_2 = """{
335
+ "death_related":false,
336
+ "political_related":false,
337
+ "woke_related":false,
338
+ "spoilers":false,
339
+ "sensitive_theme":false,
340
+ "contains_video":false,
341
+ "is_news_content":true,
342
+ "relevance":"medium",
343
+ "brazil_interest":true,
344
+ "breaking_news":false,
345
+ "audience_age_rating":10,
346
+ "regional_focus":"americas",
347
+ "country_focus":"us",
348
+ "ideological_alignment":"apolitical",
349
+ "entity_type":"movie",
350
+ "entity_name":"My Best Friend's Wedding",
351
+ "duplication":true
352
+ }"""
353
+
354
+ # Terceiro exemplo - SÉRIE COM SPOILERS E MORTE DE PERSONAGEM
355
+ EXAMPLE_INPUT_3 = f"""Title: 9-1-1: Death of main character shakes series, which gets new date for the 9th season
356
+ 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.
357
+ Last titles:
358
+ - The Batman 2 ganha data oficial de lançamento para 2026 na Warner Bros
359
+ - Datas de estreia da ABC no outono de 2025: '9-1-1', 'Nashville' e 'Grey's Anatomy' antecipadas
360
+ - Warner Bros. anuncia sequência de 'The Batman' para 2026"""
361
+
362
+ EXAMPLE_OUTPUT_3 = """{
363
+ "death_related":false,
364
+ "political_related":false,
365
+ "woke_related":false,
366
+ "spoilers":true,
367
+ "sensitive_theme":false,
368
+ "contains_video":false,
369
+ "is_news_content":true,
370
+ "relevance":"high",
371
+ "brazil_interest":true,
372
+ "breaking_news":true,
373
+ "audience_age_rating":14,
374
+ "regional_focus":"global",
375
+ "country_focus":null,
376
+ "ideological_alignment":"apolitical",
377
+ "entity_type":"series",
378
+ "entity_name":"9-1-1",
379
+ "duplication":true
380
+ }"""
381
+
382
+ # Quarto exemplo - MORTE DE CELEBRIDADE
383
+ EXAMPLE_INPUT_4 = f"""Title: Julian McMahon, 'Fantastic Four,' 'Nip/Tuck' and 'FBI: Most Wanted' Star, Dies at 56
384
+ 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."
385
+ Last titles:
386
+ - Mortes de Celebridades em 2025: Estrelas que Perdemos Este Ano
387
+ - Programas de TV Cancelados em 2025: Quais Séries Foram Canceladas
388
+ - Atores Australianos que Estão Fazendo Sucesso em Hollywood"""
389
+
390
+ EXAMPLE_OUTPUT_4 = """{
391
+ "death_related":true,
392
+ "political_related":false,
393
+ "woke_related":false,
394
+ "spoilers":false,
395
+ "sensitive_theme":true,
396
+ "contains_video":false,
397
+ "is_news_content":true,
398
+ "relevance":"high",
399
+ "brazil_interest":true,
400
+ "breaking_news":true,
401
+ "audience_age_rating":14,
402
+ "regional_focus":"americas",
403
+ "country_focus":"au",
404
+ "ideological_alignment":"apolitical",
405
+ "entity_type":"person",
406
+ "entity_name":"Julian McMahon",
407
+ "duplication":false
408
+ }"""
409
+
410
+ # Quinto exemplo - SEQUÊNCIA DE FILME COM ELEMENTOS POLÍTICOS
411
+ EXAMPLE_INPUT_5 = f"""Title: Mikey Madison and Jeremy Allen White Circling Lead Roles in Aaron Sorkin's 'Social Network' Sequel
412
+ Content: Mikey Madison and Jeremy Allen White are circling the lead roles for Aaron Sorkin's sequel to the 2010 Oscar winner "The Social Network," according to sources with knowledge of the project. While no offers have been made, Sorkin has met with both Madison and White about the project. The film is still very much in the development stage and has yet to receive the green light from Sony.
413
+ Last titles:
414
+ - Wild Bill Wichrowski do 'Deadliest Catch' ficará de fora da 21ª temporada após batalha contra o câncer de próstata
415
+ - Loni Anderson, estrela de 'WKRP in Cincinnati', morre aos 79 anos
416
+ - O filme "esquecido" do universo "Invocação do Mal": entenda por que "A Maldição da Chorona" é considerado o pior da franquia
417
+ - Rose Byrne em colapso: novo filme da A24 é descrito como 'teste de resistência'
418
+ - Jornada nas Estrelas: como entender a linha do tempo de uma das maiores sagas da ficção
419
+ - Crise na Mubi: cineastas de peso, incluindo israelenses, exigem boicote por laços com investidor militar"""
420
+
421
+ EXAMPLE_OUTPUT_5 = """{
422
+ "death_related":false,
423
+ "political_related":true,
424
+ "woke_related":false,
425
+ "spoilers":false,
426
+ "sensitive_theme":false,
427
+ "contains_video":false,
428
+ "is_news_content":true,
429
+ "relevance":"high",
430
+ "brazil_interest":true,
431
+ "breaking_news":true,
432
+ "audience_age_rating":14,
433
+ "regional_focus":"americas",
434
+ "country_focus":"au",
435
+ "ideological_alignment":"apolitical",
436
+ "entity_type":"movie",
437
+ "entity_name":"The Social Network",
438
+ "duplication":false
439
+ }"""
440
+
441
+ # Sexto exemplo - EPISÓDIO COM SPOILERS
442
+ EXAMPLE_INPUT_6 = f"""Title: Star Trek: Strange New Worlds' Holodeck Episode Began As A Tribute To A DS9 Masterpiece [Exclusive]
443
+ Content: Spoilers for episode 4 of "Star Trek: Strange New Worlds" season 4, titled "A Space Adventure Hour," episode follow. The newest episode of "Star Trek: Strange New Worlds" — "A Space Adventure Hour," written by Dana Horgan & Kathryn Lyn — features the show going back to the past. Except, it's not a time travel episode. To test a prototype holodeck, La'an (Christina Chong) crafts a murder mystery story set in mid-20th century Hollywood where she's the detective, Amelia Moon. And the suspects are the cast and crew of a space adventure series, "The Last Frontier," that's about to be canceled. The episode has enough metatext to fill the whole Enterprise, because "The Last Frontier" is a clear stand-in for "Star Trek: The Original Series." However, the writers weren't just thinking about "TOS" when it came to "A Space Adventure Hour."
444
+ Last titles:
445
+ - Wild Bill Wichrowski do 'Deadliest Catch' ficará de fora da 21ª temporada após batalha contra o câncer de próstata
446
+ - Loni Anderson, estrela de 'WKRP in Cincinnati', morre aos 79 anos
447
+ - O filme "esquecido" do universo "Invocação do Mal": entenda por que "A Maldição da Chorona" é considerado o pior da franquia
448
+ - Rose Byrne em colapso: novo filme da A24 é descrito como 'teste de resistência'
449
+ - Jornada nas Estrelas: como entender a linha do tempo de uma das maiores sagas da ficção
450
+ - Crise na Mubi: cineastas de peso, incluindo israelenses, exigem boicote por laços com investidor militar"""
451
+
452
+ EXAMPLE_OUTPUT_6 = """{
453
+ "death_related": false,
454
+ "political_related": false,
455
+ "woke_related": false,
456
+ "spoilers": true,
457
+ "sensitive_theme": false,
458
+ "contains_video": false,
459
+ "is_news_content": true,
460
+ "relevance": "medium",
461
+ "brazil_interest": true,
462
+ "breaking_news": false,
463
+ "audience_age_rating": 10,
464
+ "regional_focus": "global",
465
+ "country_focus": "us",
466
+ "ideological_alignment": "apolitical",
467
+ "entity_type": "series",
468
+ "entity_name": "Star Trek: Strange New Worlds",
469
+ "duplication": false
470
+ }"""
471
+
472
+ # Sétimo exemplo - SÉRIE DE HORROR (TEMA SENSÍVEL)
473
+ EXAMPLE_INPUT_7 = f"""Title: 'Hostel' TV Series From Eli Roth and Starring Paul Giamatti Lands at Peacock for Development (Exclusive)
474
+ Content: The "Hostel" TV series has found a home at Peacock. Variety has learned exclusively that the TV extension of the horror film franchise is currently in development at the NBCUniversal streamer. The show was previously reported to be in the works in June 2024, but no platform was attached at that time. As originally reported, Paul Giamatti is attached to star in the series, with "Hostel" mastermind Eli Roth set to write, direct, and executive produce. Chris Briggs and Mike Fleiss, who have produced all the "Hostel" films, are also executive producers. Fifth Season is the studio. Exact plot details are being kept under wraps.
475
+ Last titles:
476
+ - Wild Bill Wichrowski do 'Deadliest Catch' ficará de fora da 21ª temporada após batalha contra o câncer de próstata
477
+ - Loni Anderson, estrela de 'WKRP in Cincinnati', morre aos 79 anos
478
+ - O filme "esquecido" do universo "Invocação do Mal": entenda por que "A Maldição da Chorona" é considerado o pior da franquia
479
+ - Rose Byrne em colapso: novo filme da A24 é descrito como 'teste de resistência'
480
+ - Jornada nas Estrelas: como entender a linha do tempo de uma das maiores sagas da ficção
481
+ - Crise na Mubi: cineastas de peso, incluindo israelenses, exigem boicote por laços com investidor militar"""
482
+
483
+ EXAMPLE_OUTPUT_7 = """{
484
+ "death_related": false,
485
+ "political_related": false,
486
+ "woke_related": false,
487
+ "spoilers": false,
488
+ "sensitive_theme": true,
489
+ "contains_video": false,
490
+ "is_news_content": true,
491
+ "relevance": "medium",
492
+ "brazil_interest": false,
493
+ "breaking_news": false,
494
+ "audience_age_rating": 18,
495
+ "regional_focus": "global",
496
+ "country_focus": "us",
497
+ "ideological_alignment": "apolitical",
498
+ "entity_type": "series",
499
+ "entity_name": "Hostel",
500
+ "duplication": false
501
+ }"""
502
+
503
+ # Oitavo exemplo - EVENTO ESPORTIVO
504
+ EXAMPLE_INPUT_8 = f"""Title: Is Canelo vs. Crawford Free on Netflix? Here's How to Watch the Fight
505
+ Content: When boxing legends Saúl "Canelo" Álvarez and Terence "Bud" Crawford meet in the ring on Sept. 13, it won't just be a clash of champions — it could be a career-defining moment. For the first time ever two of the most dominant fighters of their generation will share the ring. Only one will walk away as the greatest of their era. Given the high stakes and the long tradition of pay-per-view boxing events, fans are asking: Is Canelo vs. Crawford free on Netflix? Keep scrolling to learn more.
506
+ Last titles:
507
+ - Wild Bill Wichrowski do 'Deadliest Catch' ficará de fora da 21ª temporada após batalha contra o câncer de próstata
508
+ - Loni Anderson, estrela de 'WKRP in Cincinnati', morre aos 79 anos
509
+ - O filme "esquecido" do universo "Invocação do Mal": entenda por que "A Maldição da Chorona" é considerado o pior da franquia
510
+ - Rose Byrne em colapso: novo filme da A24 é descrito como 'teste de resistência'
511
+ - Jornada nas Estrelas: como entender a linha do tempo de uma das maiores sagas da ficção
512
+ - Crise na Mubi: cineastas de peso, incluindo israelenses, exigem boicote por laços com investidor militar"""
513
+
514
+ EXAMPLE_OUTPUT_8 = """{
515
+ "death_related": false,
516
+ "political_related": false,
517
+ "woke_related": false,
518
+ "spoilers": false,
519
+ "sensitive_theme": false,
520
+ "contains_video": false,
521
+ "is_news_content": true,
522
+ "relevance": "high",
523
+ "brazil_interest": true,
524
+ "breaking_news": false,
525
+ "audience_age_rating": 10,
526
+ "regional_focus": "global",
527
+ "country_focus": "us",
528
+ "ideological_alignment": "apolitical",
529
+ "entity_type": "event",
530
+ "entity_name": "Canelo Álvarez vs. Terence Crawford",
531
+ "duplication": false
532
+ }"""
533
+
534
+ # Nono exemplo - MORTE DE CELEBRIDADE (DUPLICAÇÃO)
535
+ EXAMPLE_INPUT_9 = f"""Title: Loni Anderson, Emmy- and Golden Globe-Nominated Star of 'Wkrp in Cincinnati,' Dies at 79
536
+ Content: Loni Anderson, whose beloved role as Jennifer Marlowe on "WKRP in Cincinnati" was nominated for Emmy and Golden Globe awards, has died, her publicist confirmed Sunday. She was 79.
537
+ Last titles:
538
+ - Wild Bill Wichrowski do 'Deadliest Catch' ficará de fora da 21ª temporada após batalha contra o câncer de próstata
539
+ - Loni Anderson, estrela de 'WKRP in Cincinnati', morre aos 79 anos
540
+ - O filme "esquecido" do universo "Invocação do Mal": entenda por que "A Maldição da Chorona" é considerado o pior da franquia
541
+ - Rose Byrne em colapso: novo filme da A24 é descrito como 'teste de resistência'
542
+ - Jornada nas Estrelas: como entender a linha do tempo de uma das maiores sagas da ficção
543
+ - Crise na Mubi: cineastas de peso, incluindo israelenses, exigem boicote por laços com investidor militar
544
+ - Liam Neeson e Joe Keery enfrentam terror biológico no trailer de Cold Storage
545
+ - TIFF 2025: de John Candy a Lucrecia Martel, conheça os documentários do ano"""
546
+
547
+ EXAMPLE_OUTPUT_9 = """{
548
+ "death_related": true,
549
+ "political_related": false,
550
+ "woke_related": false,
551
+ "spoilers": false,
552
+ "sensitive_theme": false,
553
+ "contains_video": false,
554
+ "is_news_content": true,
555
+ "relevance": "medium",
556
+ "brazil_interest": false,
557
+ "breaking_news": true,
558
+ "audience_age_rating": 10,
559
+ "regional_focus": "global",
560
+ "country_focus": "us",
561
+ "ideological_alignment": "apolitical",
562
+ "entity_type": "person",
563
+ "entity_name": "Loni Anderson",
564
+ "duplication": true
565
+ }"""
566
+
567
+ # Décimo exemplo - FILME DE FESTIVAL (BAIXA RELEVÂNCIA)
568
+ EXAMPLE_INPUT_10 = f"""Title: Jim Jarmusch's 'Father Mother Sister Brother' Sells to Multiple Territories Ahead of Venice Premiere
569
+ Content: Jim Jarmusch's "Father Mother Sister Brother" has sold to multiple territories ahead of its world premiere in competition at the Venice Film Festival. The film stars Tom Waits, Adam Driver, Mayim Bialik, Charlotte Rampling, Cate Blanchett, Vicky Krieps, Sarah Greene, Indya Moore, Luka Sabbat and Françoise Lebrun. Distribution rights have been picked up in Italy (Lucky Red), Spain (Avalon Distribucion Audiovisual), Portugal (Nos Lusomundo), Greece (Cinobo), Poland (Gutek Film), Hungary (Cirko Films), Romania (Bad Unicorn), Former Yugoslavia (MCF MegaCom Film), Czech Republic and Slovakia (Aerofilms), Middle East and North Africa (Front Row Filmed Ent.), South Korea (Andamiro Films), and Hong Kong (Edko Films).
570
+ Last titles:
571
+ - Wild Bill Wichrowski do 'Deadliest Catch' ficará de fora da 21ª temporada após batalha contra o câncer de próstata
572
+ - Loni Anderson, estrela de 'WKRP in Cincinnati', morre aos 79 anos
573
+ - O filme "esquecido" do universo "Invocação do Mal": entenda por que "A Maldição da Chorona" é considerado o pior da franquia
574
+ - Rose Byrne em colapso: novo filme da A24 é descrito como 'teste de resistência'
575
+ - Jornada nas Estrelas: como entender a linha do tempo de uma das maiores sagas da ficção
576
+ - Crise na Mubi: cineastas de peso, incluindo israelenses, exigem boicote por laços com investidor militar
577
+ - Universo 'Stranger Things' se expande: série animada e peça de teatro são confirmadas
578
+ - Wandinha: O que já sabemos sobre a 2ª temporada e os boatos que circulam na internet
579
+ - Novo filme de Park Chan-wook, 'No Other Choice', escala festivais e une estrelas
580
+ - Homem-Aranha 4: Tom Holland revela novo traje e produção de 'Um Novo Dia' começa com participações surpreendentes
581
+ - Quarteto Fantástico segue no topo das bilheterias, mas queda preocupa
582
+ - Novo filme de Jim Jarmusch com Adam Driver e Cate Blanchett será distribuído pela MUBI
583
+ - Tulsa King: 3ª temporada com Sylvester Stallone ganha data de estreia e primeiras imagens"""
584
+
585
+ EXAMPLE_OUTPUT_10 = """{
586
+ "death_related": false,
587
+ "political_related": false,
588
+ "woke_related": false,
589
+ "spoilers": false,
590
+ "sensitive_theme": false,
591
+ "contains_video": false,
592
+ "is_news_content": true,
593
+ "relevance": "low",
594
+ "brazil_interest": false,
595
+ "breaking_news": false,
596
+ "audience_age_rating": 10,
597
+ "regional_focus": "global",
598
+ "country_focus": "us",
599
+ "ideological_alignment": "apolitical",
600
+ "entity_type": "movie",
601
+ "entity_name": "Father Mother Sister Brother",
602
+ "duplication": true
603
+ }"""
604
+
605
+ EXAMPLE_INPUT_11 = f"""Title: ‘AGT’: Husband & Wife Comedians Audition Against Each Other — Did Either Make the Live Shows?
606
+ Content: Press The Golden Buzzer! For exclusive news and updates, subscribe to our America's Got Talent Newsletter:\n\nAmerica’s Got Talent has seen several couples audition together over the years, but it’s rare to see a husband and wife competing against one another. But that’s exactly what happened on Tuesday’s (August 5) episode.\n\nComedian Matt O’Brien and his wife, Julia Hladkowicz, also a comic, both auditioned for the NBC competition series separately. O’Brien was up first, winning the judges over with his jokes about being married versus being single.\n\n“You are really, really good,” Howie Mandel told the Canadian comic. “You deserve to be here. You’re the kind of comedian that could go really far in this, so I want to be the first one to give you a yes.”
607
+ Last titles:
608
+ - Wild Bill Wichrowski do 'Deadliest Catch' ficará de fora da 21ª temporada após batalha contra o câncer de próstata
609
+ - Loni Anderson, estrela de 'WKRP in Cincinnati', morre aos 79 anos
610
+ - O filme \"esquecido\" do universo \"Invocação do Mal\": entenda por que \"A Maldição da Chorona\" é considerado o pior da franquia
611
+ - Rose Byrne em colapso: novo filme da A24 é descrito como 'teste de resistência'
612
+ - Jornada nas Estrelas: como entender a linha do tempo de uma das maiores sagas da ficção
613
+ - Crise na Mubi: cineastas de peso, incluindo israelenses, exigem boicote por laços com investidor militar
614
+ - Universo 'Stranger Things' se expande: série animada e peça de teatro são confirmadas
615
+ - Wandinha: O que já sabemos sobre a 2ª temporada e os boatos que circulam na internet
616
+ - Novo filme de Park Chan-wook, 'No Other Choice', escala festivais e une estrelas
617
+ - Homem-Aranha 4: Tom Holland revela novo traje e produção de 'Um Novo Dia' começa com participações surpreendentes
618
+ - Quarteto Fantástico segue no topo das bilheterias, mas queda preocupa"""
619
+
620
+ EXAMPLE_OUTPUT_11 = """{
621
+ "death_related": false,
622
+ "political_related": false,
623
+ "woke_related": false,
624
+ "spoilers": true,
625
+ "sensitive_theme": false,
626
+ "contains_video": false,
627
+ "is_news_content": true,
628
+ "relevance": "medium",
629
+ "brazil_interest": false,
630
+ "breaking_news": false,
631
+ "audience_age_rating": 10,
632
+ "regional_focus": "global",
633
+ "country_focus": "us",
634
+ "ideological_alignment": "apolitical",
635
+ "entity_type": "series",
636
+ "entity_name": "America's Got Talent",
637
+ "duplication": false
638
+ }"""
639
+
640
+ EXAMPLE_INPUT_12 = f"""Title: Savannah Guthrie Has Emotional Reunion With Kids Amid ’Today’ Absence
641
+ Content: Savannah Guthrie returned to Today‘s Studio 1A on Wednesday, August 6, but not before picking up her kids from summer camp.\n\nThe news anchor enjoyed the end of her two-day Today absence by reuniting with her 10-year-old daughter, Vale, and 8-year-old son, Charley. Guthrie shared several photos from the camp pick-up via her Instagram Story on Tuesday, August 5, including individual snaps of herself hugging each of her children and a group selfie the three of them took together.\n\nShe also poked fun at her children by criticizing their hygiene habits. “There is no greater act of motherly love than touching the post-camp retainer 🤢,” she hilariously wrote over a snap of one of the kids’
642
+ Last titles:
643
+ - Wild Bill Wichrowski do 'Deadliest Catch' ficará de fora da 21ª temporada após batalha contra o câncer de próstata
644
+ - Loni Anderson, estrela de 'WKRP in Cincinnati', morre aos 79 anos
645
+ - O filme \"esquecido\" do universo \"Invocação do Mal\": entenda por que \"A Maldição da Chorona\" é considerado o pior da franquia
646
+ - Rose Byrne em colapso: novo filme da A24 é descrito como 'teste de resistência'
647
+ - Jornada nas Estrelas: como entender a linha do tempo de uma das maiores sagas da ficção
648
+ - Crise na Mubi: cineastas de peso, incluindo israelenses, exigem boicote por laços com investidor militar
649
+ - Universo 'Stranger Things' se expande: série animada e peça de teatro são confirmadas
650
+ - Wandinha: O que já sabemos sobre a 2ª temporada e os boatos que circulam na internet
651
+ - Novo filme de Park Chan-wook, 'No Other Choice', escala festivais e une estrelas
652
+ - Homem-Aranha 4: Tom Holland revela novo traje e produção de 'Um Novo Dia' começa com participações surpreendentes
653
+ - Quarteto Fantástico segue no topo das bilheterias, mas queda preocupa
654
+ - Novo filme de Jim Jarmusch com Adam Driver e Cate Blanchett será distribuído pela MUBI
655
+ - Tulsa King: 3ª temporada com Sylvester Stallone ganha data de estreia e primeiras imagens"""
656
+
657
+ EXAMPLE_OUTPUT_12 = """{
658
+ "death_related": false,
659
+ "political_related": false,
660
+ "woke_related": false,
661
+ "spoilers": false,
662
+ "sensitive_theme": false,
663
+ "contains_video": false,
664
+ "is_news_content": true,
665
+ "relevance": "medium",
666
+ "brazil_interest": false,
667
+ "breaking_news": false,
668
+ "audience_age_rating": 10,
669
+ "regional_focus": "americas",
670
+ "country_focus": "us",
671
+ "ideological_alignment": "apolitical",
672
+ "entity_type": "person",
673
+ "entity_name": "Savannah Guthrie",
674
+ "duplication": false
675
+ }"""
676
+
677
+ # Estrutura de conversação correta com múltiplos exemplos
678
+ contents = [
679
+ # Primeiro exemplo
680
+ types.Content(
681
+ role="user",
682
+ parts=[
683
+ types.Part.from_text(text=EXAMPLE_INPUT_1)
684
+ ]
685
+ ),
686
+ types.Content(
687
+ role="model",
688
+ parts=[
689
+ types.Part.from_text(text=EXAMPLE_OUTPUT_1)
690
+ ]
691
+ ),
692
+ # Segundo exemplo
693
+ types.Content(
694
+ role="user",
695
+ parts=[
696
+ types.Part.from_text(text=EXAMPLE_INPUT_2)
697
+ ]
698
+ ),
699
+ types.Content(
700
+ role="model",
701
+ parts=[
702
+ types.Part.from_text(text=EXAMPLE_OUTPUT_2)
703
+ ]
704
+ ),
705
+ # Terceiro exemplo
706
+ types.Content(
707
+ role="user",
708
+ parts=[
709
+ types.Part.from_text(text=EXAMPLE_INPUT_3)
710
+ ]
711
+ ),
712
+ types.Content(
713
+ role="model",
714
+ parts=[
715
+ types.Part.from_text(text=EXAMPLE_OUTPUT_3)
716
+ ]
717
+ ),
718
+ # Quarto exemplo
719
+ types.Content(
720
+ role="user",
721
+ parts=[
722
+ types.Part.from_text(text=EXAMPLE_INPUT_4)
723
+ ]
724
+ ),
725
+ types.Content(
726
+ role="model",
727
+ parts=[
728
+ types.Part.from_text(text=EXAMPLE_OUTPUT_4)
729
+ ]
730
+ ),
731
+ # Quinto exemplo
732
+ types.Content(
733
+ role="user",
734
+ parts=[
735
+ types.Part.from_text(text=EXAMPLE_INPUT_5)
736
+ ]
737
+ ),
738
+ types.Content(
739
+ role="model",
740
+ parts=[
741
+ types.Part.from_text(text=EXAMPLE_OUTPUT_5)
742
+ ]
743
+ ),
744
+ # Sexto exemplo
745
+ types.Content(
746
+ role="user",
747
+ parts=[
748
+ types.Part.from_text(text=EXAMPLE_INPUT_6)
749
+ ]
750
+ ),
751
+ types.Content(
752
+ role="model",
753
+ parts=[
754
+ types.Part.from_text(text=EXAMPLE_OUTPUT_6)
755
+ ]
756
+ ),
757
+ # Sétimo exemplo
758
+ types.Content(
759
+ role="user",
760
+ parts=[
761
+ types.Part.from_text(text=EXAMPLE_INPUT_7)
762
+ ]
763
+ ),
764
+ types.Content(
765
+ role="model",
766
+ parts=[
767
+ types.Part.from_text(text=EXAMPLE_OUTPUT_7)
768
+ ]
769
+ ),
770
+ # Oitavo exemplo
771
+ types.Content(
772
+ role="user",
773
+ parts=[
774
+ types.Part.from_text(text=EXAMPLE_INPUT_8)
775
+ ]
776
+ ),
777
+ types.Content(
778
+ role="model",
779
+ parts=[
780
+ types.Part.from_text(text=EXAMPLE_OUTPUT_8)
781
+ ]
782
+ ),
783
+ # Nono exemplo
784
+ types.Content(
785
+ role="user",
786
+ parts=[
787
+ types.Part.from_text(text=EXAMPLE_INPUT_9)
788
+ ]
789
+ ),
790
+ types.Content(
791
+ role="model",
792
+ parts=[
793
+ types.Part.from_text(text=EXAMPLE_OUTPUT_9)
794
+ ]
795
+ ),
796
+ # Décimo exemplo
797
+ types.Content(
798
+ role="user",
799
+ parts=[
800
+ types.Part.from_text(text=EXAMPLE_INPUT_10)
801
+ ]
802
+ ),
803
+ types.Content(
804
+ role="model",
805
+ parts=[
806
+ types.Part.from_text(text=EXAMPLE_OUTPUT_10)
807
+ ]
808
+ ),
809
+ types.Content(
810
+ role="user",
811
+ parts=[
812
+ types.Part.from_text(text=EXAMPLE_INPUT_11)
813
+ ]
814
+ ),
815
+ types.Content(
816
+ role="model",
817
+ parts=[
818
+ types.Part.from_text(text=EXAMPLE_OUTPUT_11)
819
+ ]
820
+ ),
821
+ types.Content(
822
+ role="user",
823
+ parts=[
824
+ types.Part.from_text(text=EXAMPLE_INPUT_12)
825
+ ]
826
+ ),
827
+ types.Content(
828
+ role="model",
829
+ parts=[
830
+ types.Part.from_text(text=EXAMPLE_OUTPUT_12)
831
+ ]
832
+ ),
833
+ # Agora o usuário envia a notícia real para ser analisada
834
+ types.Content(
835
+ role="user",
836
+ parts=[
837
+ types.Part.from_text(text=f"""Title: {title}
838
+ Content: {content}
839
+ Last titles:
840
+ - {last_titles_formatted}""")
841
+ ]
842
+ )
843
+ ]
844
+
845
+ # Ferramentas para pesquisa e pensamento
846
+ tools = [
847
+ types.Tool(googleSearch=types.GoogleSearch())
848
+ ]
849
+
850
+ config = types.GenerateContentConfig(
851
+ system_instruction=SYSTEM_INSTRUCTIONS,
852
+ tools=tools,
853
+ response_mime_type="text/plain",
854
+ max_output_tokens=4096,
855
+ temperature=0.8,
856
+ )
857
+
858
+ response_text = ""
859
+ for chunk in client.models.generate_content_stream(
860
+ model=model,
861
+ contents=contents,
862
+ config=config
863
+ ):
864
+ if chunk.text:
865
+ response_text += chunk.text
866
+
867
+ json_result = extract_json(response_text)
868
+
869
+ try:
870
+ parsed = json.loads(json_result)
871
+ except json.JSONDecodeError as e:
872
+ raise ValueError("Modelo retornou JSON inválido")
873
+
874
+ ALLOWED_KEYS = {
875
+ "death_related", "political_related", "woke_related", "spoilers",
876
+ "sensitive_theme", "contains_video", "is_news_content", "relevance",
877
+ "brazil_interest", "breaking_news", "audience_age_rating", "regional_focus",
878
+ "country_focus", "ideological_alignment", "entity_type", "entity_name", "duplication"
879
+ }
880
+
881
+ clean_filter = {key: parsed[key] for key in ALLOWED_KEYS if key in parsed}
882
+ clean_filter = ensure_filter_order(clean_filter)
883
+
884
+ return {"filter": clean_filter}
885
+
886
+ except Exception as e:
887
+ raise ValueError(f"Erro na filtragem: {str(e)}")
888
+
889
+ def should_skip_insertion(filters: dict) -> tuple[bool, str]:
890
+ """
891
+ Verifica se a notícia deve ser pulada (não inserida na tabela news).
892
+ Retorna (should_skip, reason)
893
+ """
894
+
895
+ # Condição 1: Se duplication for true → sempre pular
896
+ if filters.get("duplication", False):
897
+ return True, "duplicação detectada"
898
+
899
+ # Condição 2: Se is_news_content for false → pular
900
+ if not filters.get("is_news_content", True):
901
+ return True, "conteúdo não é notícia (review, lista, crítica, etc.)"
902
+
903
+ # Condição 3: Se brazil_interest for false → pular
904
+ if not filters.get("brazil_interest", True):
905
+ return True, "baixo interesse para o Brasil (brazil_interest=false)"
906
+
907
+ # Condição 4: Se relevance for low ou ausente → pular
908
+ if filters.get("relevance", "") not in {"medium", "high", "viral"}:
909
+ return True, f"relevância insuficiente (relevance={filters.get('relevance')})"
910
+
911
+ # Se passou por todas, pode inserir
912
+ return False, ""
913
+
914
+ app = FastAPI(title="News Filter API")
915
+ router = APIRouter()
916
+
917
+ @router.post("/filter")
918
+ async def filter_endpoint():
919
+ news_data = None
920
+ news_id = None
921
+
922
+ try:
923
+ # Busca notícia não usada do Supabase
924
+ news_data = await fetch_unused_news()
925
+
926
+ title = news_data.get("title", "")
927
+ url = news_data.get("url", "")
928
+ news_id = news_data.get("news_id", "")
929
+ image_url = news_data.get("image", "")
930
+
931
+ if not title.strip() or not url.strip():
932
+ raise ValueError("Title e URL não podem estar vazios")
933
+
934
+ log.info(f"Processando notícia {news_id}: {title}")
935
+
936
+ # Busca os últimos 50 títulos
937
+ last_titles = await fetch_last_50_titles()
938
+
939
+ # Extrai texto completo da URL
940
+ full_text = await extract_article_text(url)
941
+
942
+ if not full_text.strip():
943
+ raise ValueError("Não foi possível extrair texto da URL")
944
+
945
+ # Executa análise de filtros com os últimos títulos
946
+ filter_result = await filter_news(title, full_text, last_titles)
947
+
948
+ # Verifica se deve pular a inserção
949
+ should_skip, skip_reason = should_skip_insertion(filter_result["filter"])
950
+
951
+ if should_skip:
952
+ # Apenas marca como usada, não insere na tabela news
953
+ await mark_news_as_used(news_id)
954
+ log.info(f"Notícia {news_id} pulada devido a: {skip_reason}")
955
+
956
+ return {
957
+ "filter": filter_result["filter"],
958
+ "title_en": title,
959
+ "text_en": full_text,
960
+ "news_id": news_id,
961
+ "url": url,
962
+ "image": image_url,
963
+ "last_titles": last_titles,
964
+ "skipped": True,
965
+ "skip_reason": skip_reason
966
+ }
967
+ else:
968
+ # Insere na tabela news com filtros
969
+ await insert_news_to_db(title, full_text, news_id, url, image_url, filter_result["filter"])
970
+
971
+ # Marca como usada (sucesso)
972
+ await mark_news_as_used(news_id)
973
+
974
+ log.info(f"Notícia {news_id} processada e inserida com sucesso")
975
+
976
+ return {
977
+ "filter": filter_result["filter"],
978
+ "title_en": title,
979
+ "text_en": full_text,
980
+ "news_id": news_id,
981
+ "url": url,
982
+ "image": image_url,
983
+ "last_titles": last_titles,
984
+ "skipped": False
985
+ }
986
+
987
+ except Exception as e:
988
+ error_msg = str(e)
989
+ log.error(f"Erro no processamento da notícia {news_id}: {error_msg}")
990
+
991
+ # SEMPRE marca como usada em caso de erro para evitar loops infinitos
992
+ if news_id:
993
+ await mark_news_as_used(news_id)
994
+
995
+ # Determina o tipo de erro para o HTTP response
996
+ if "Nenhuma notícia disponível" in error_msg:
997
+ raise HTTPException(status_code=404, detail=error_msg)
998
+ elif "Title e URL não podem estar vazios" in error_msg:
999
+ raise HTTPException(status_code=400, detail=error_msg)
1000
+ elif "Não foi possível extrair texto" in error_msg:
1001
+ raise HTTPException(status_code=400, detail=error_msg)
1002
+ else:
1003
+ raise HTTPException(status_code=500, detail=f"Erro interno: {error_msg}")
1004
+
1005
+ app.include_router(router)
1006
+
1007
+ @app.on_event("shutdown")
1008
+ async def shutdown_event():
1009
+ global http_session
1010
+ if http_session:
1011
+ 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,701 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, Union
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 parse_image_urls(image_url_param: Union[str, List[str]]) -> List[str]:
43
+ """Converte o parâmetro de URL(s) em lista de URLs"""
44
+ if isinstance(image_url_param, list):
45
+ return image_url_param
46
+ elif isinstance(image_url_param, str):
47
+ # Se contém vírgulas, divide em múltiplas URLs
48
+ if ',' in image_url_param:
49
+ return [url.strip() for url in image_url_param.split(',') if url.strip()]
50
+ else:
51
+ return [image_url_param]
52
+ return []
53
+
54
+ def create_collage_background(image_urls: List[str], canvas_width: int, canvas_height: int) -> Image.Image:
55
+ """Cria uma colagem como fundo baseada na lista de URLs para Instagram"""
56
+ num_images = len(image_urls)
57
+ border_size = 4 if num_images > 1 else 0 # Linha mais fina e elegante
58
+
59
+ images = [download_image_from_url(url) for url in image_urls]
60
+ canvas = Image.new("RGBA", (canvas_width, canvas_height), (255, 255, 255, 255))
61
+
62
+ if num_images == 1:
63
+ img = resize_and_crop_to_fill(images[0], canvas_width, canvas_height)
64
+ canvas.paste(img, (0, 0))
65
+
66
+ elif num_images == 2:
67
+ # Lado a lado
68
+ slot_width = (canvas_width - border_size) // 2
69
+ img1 = resize_and_crop_to_fill(images[0], slot_width, canvas_height)
70
+ img2 = resize_and_crop_to_fill(images[1], slot_width, canvas_height)
71
+ canvas.paste(img1, (0, 0))
72
+ canvas.paste(img2, (slot_width + border_size, 0))
73
+
74
+ elif num_images == 3:
75
+ # Layout: 2 em cima, 1 embaixo
76
+ half_height = (canvas_height - border_size) // 2
77
+ half_width = (canvas_width - border_size) // 2
78
+ img1 = resize_and_crop_to_fill(images[0], half_width, half_height)
79
+ img2 = resize_and_crop_to_fill(images[1], half_width, half_height)
80
+ img3 = resize_and_crop_to_fill(images[2], canvas_width, half_height)
81
+ canvas.paste(img1, (0, 0))
82
+ canvas.paste(img2, (half_width + border_size, 0))
83
+ canvas.paste(img3, (0, half_height + border_size))
84
+
85
+ elif num_images == 4:
86
+ # Layout 2x2
87
+ half_height = (canvas_height - border_size) // 2
88
+ half_width = (canvas_width - border_size) // 2
89
+ img1 = resize_and_crop_to_fill(images[0], half_width, half_height)
90
+ img2 = resize_and_crop_to_fill(images[1], half_width, half_height)
91
+ img3 = resize_and_crop_to_fill(images[2], half_width, half_height)
92
+ img4 = resize_and_crop_to_fill(images[3], half_width, half_height)
93
+ canvas.paste(img1, (0, 0))
94
+ canvas.paste(img2, (half_width + border_size, 0))
95
+ canvas.paste(img3, (0, half_height + border_size))
96
+ canvas.paste(img4, (half_width + border_size, half_height + border_size))
97
+
98
+ elif num_images == 5:
99
+ # Layout: 2 em cima, 3 embaixo
100
+ top_height = (canvas_height - border_size) * 2 // 5
101
+ bottom_height = canvas_height - top_height - border_size
102
+ half_width = (canvas_width - border_size) // 2
103
+
104
+ img1 = resize_and_crop_to_fill(images[0], half_width, top_height)
105
+ img2 = resize_and_crop_to_fill(images[1], half_width, top_height)
106
+ canvas.paste(img1, (0, 0))
107
+ canvas.paste(img2, (half_width + border_size, 0))
108
+
109
+ y_offset = top_height + border_size
110
+ third_width = (canvas_width - 2 * border_size) // 3
111
+ third_width_last = canvas_width - (third_width * 2 + border_size * 2)
112
+
113
+ img3 = resize_and_crop_to_fill(images[2], third_width, bottom_height)
114
+ img4 = resize_and_crop_to_fill(images[3], third_width, bottom_height)
115
+ img5 = resize_and_crop_to_fill(images[4], third_width_last, bottom_height)
116
+ canvas.paste(img3, (0, y_offset))
117
+ canvas.paste(img4, (third_width + border_size, y_offset))
118
+ canvas.paste(img5, (third_width * 2 + border_size * 2, y_offset))
119
+
120
+ elif num_images == 6:
121
+ # Layout 3x2
122
+ half_height = (canvas_height - border_size) // 2
123
+ third_width = (canvas_width - 2 * border_size) // 3
124
+ third_width_last = canvas_width - (third_width * 2 + border_size * 2)
125
+
126
+ # Primeira linha
127
+ img1 = resize_and_crop_to_fill(images[0], third_width, half_height)
128
+ img2 = resize_and_crop_to_fill(images[1], third_width, half_height)
129
+ img3 = resize_and_crop_to_fill(images[2], third_width, half_height)
130
+ canvas.paste(img1, (0, 0))
131
+ canvas.paste(img2, (third_width + border_size, 0))
132
+ canvas.paste(img3, (third_width * 2 + border_size * 2, 0))
133
+
134
+ # Segunda linha
135
+ y_offset = half_height + border_size
136
+ img4 = resize_and_crop_to_fill(images[3], third_width, half_height)
137
+ img5 = resize_and_crop_to_fill(images[4], third_width, half_height)
138
+ img6 = resize_and_crop_to_fill(images[5], third_width_last, half_height)
139
+ canvas.paste(img4, (0, y_offset))
140
+ canvas.paste(img5, (third_width + border_size, y_offset))
141
+ canvas.paste(img6, (third_width * 2 + border_size * 2, y_offset))
142
+
143
+ else:
144
+ raise HTTPException(status_code=400, detail="Apenas até 6 imagens são suportadas.")
145
+
146
+ return canvas
147
+
148
+ def create_gradient_overlay(width: int, height: int, positions: List[str], expanded: bool = False) -> Image.Image:
149
+ gradient = Image.new("RGBA", (width, height))
150
+ draw = ImageDraw.Draw(gradient)
151
+
152
+ for position in positions:
153
+ if position.lower() == "bottom":
154
+ if expanded:
155
+ # Gradiente expandido para quando há texto + citação
156
+ gradient_start = 500 # Começar mais alto
157
+ gradient_height = 850 # Mais altura para cobrir ambos
158
+ else:
159
+ # Gradiente normal
160
+ gradient_start = 650 # Começar mais baixo para ser mais sutil
161
+ gradient_height = 700 # Altura menor para gradiente mais localizado
162
+
163
+ for y in range(gradient_height):
164
+ if y + gradient_start < height:
165
+ ratio = y / gradient_height
166
+ # Gradiente muito mais suave com opacidades menores
167
+ if ratio <= 0.3:
168
+ opacity = int(255 * 0.15 * (ratio / 0.3)) # Começar muito sutil
169
+ elif ratio <= 0.6:
170
+ opacity = int(255 * (0.15 + 0.20 * ((ratio - 0.3) / 0.3))) # Crescimento gradual
171
+ else:
172
+ opacity = int(255 * (0.35 + 0.15 * ((ratio - 0.6) / 0.4))) # Max de 50% de opacidade
173
+
174
+ current_pixel = gradient.getpixel((0, y + gradient_start))
175
+ combined_opacity = min(255, current_pixel[3] + opacity)
176
+ draw.line([(0, y + gradient_start), (width, y + gradient_start)], fill=(0, 0, 0, combined_opacity))
177
+
178
+ elif position.lower() == "top":
179
+ if expanded:
180
+ # Gradiente expandido para quando há texto + citação
181
+ gradient_height = 850 # Maior altura para cobrir ambos
182
+ else:
183
+ # Gradiente normal
184
+ gradient_height = 650 # Menor altura para ser mais sutil
185
+
186
+ for y in range(gradient_height):
187
+ if y < height:
188
+ ratio = (gradient_height - y) / gradient_height
189
+ # Opacidades muito menores para efeito mais sutil
190
+ if ratio <= 0.2:
191
+ opacity = int(255 * 0.12 * (ratio / 0.2)) # Muito sutil no início
192
+ elif ratio <= 0.5:
193
+ opacity = int(255 * (0.12 + 0.18 * ((ratio - 0.2) / 0.3))) # Crescimento suave
194
+ else:
195
+ opacity = int(255 * (0.30 + 0.15 * ((ratio - 0.5) / 0.5))) # Max de 45%
196
+
197
+ current_pixel = gradient.getpixel((0, y))
198
+ combined_opacity = min(255, current_pixel[3] + opacity)
199
+ draw.line([(0, y), (width, y)], fill=(0, 0, 0, combined_opacity))
200
+
201
+ return gradient
202
+
203
+ class TextSegment:
204
+ def __init__(self, text: str, is_bold: bool = False, is_italic: bool = False):
205
+ self.text = text
206
+ self.is_bold = is_bold
207
+ self.is_italic = is_italic
208
+
209
+ def parse_text_with_formatting(text: str) -> List[TextSegment]:
210
+ pattern = r'(<(?:strong|em)>.*?</(?:strong|em)>|<(?:strong|em)><(?:strong|em)>.*?</(?:strong|em)></(?:strong|em)>)'
211
+ parts = re.split(pattern, text)
212
+ segments = []
213
+
214
+ for part in parts:
215
+ if not part:
216
+ continue
217
+
218
+ if match := re.match(r'<strong><em>(.*?)</em></strong>|<em><strong>(.*?)</strong></em>', part):
219
+ content = match.group(1) or match.group(2)
220
+ segments.append(TextSegment(content, True, True))
221
+ elif match := re.match(r'<strong>(.*?)</strong>', part):
222
+ segments.append(TextSegment(match.group(1), True, False))
223
+ elif match := re.match(r'<em>(.*?)</em>', part):
224
+ segments.append(TextSegment(match.group(1), False, True))
225
+ else:
226
+ segments.append(TextSegment(part, False, False))
227
+
228
+ return segments
229
+
230
+ def get_font(segment: TextSegment, regular_font: ImageFont.FreeTypeFont,
231
+ bold_font: ImageFont.FreeTypeFont, italic_font: ImageFont.FreeTypeFont,
232
+ bold_italic_font: ImageFont.FreeTypeFont) -> ImageFont.FreeTypeFont:
233
+ if segment.is_bold and segment.is_italic:
234
+ return bold_italic_font
235
+ elif segment.is_bold:
236
+ return bold_font
237
+ elif segment.is_italic:
238
+ return italic_font
239
+ return regular_font
240
+
241
+ def wrap_text_with_formatting(segments: List[TextSegment], regular_font: ImageFont.FreeTypeFont,
242
+ bold_font: ImageFont.FreeTypeFont, italic_font: ImageFont.FreeTypeFont,
243
+ bold_italic_font: ImageFont.FreeTypeFont, max_width: int,
244
+ draw: ImageDraw.Draw) -> List[List[TextSegment]]:
245
+ lines = []
246
+ current_line = []
247
+ current_width = 0
248
+ first_in_line = True
249
+
250
+ for segment in segments:
251
+ if not segment.text:
252
+ continue
253
+
254
+ font = get_font(segment, regular_font, bold_font, italic_font, bold_italic_font)
255
+
256
+ words = re.split(r'(\s+)', segment.text)
257
+
258
+ for word in words:
259
+ if not word:
260
+ continue
261
+
262
+ if re.match(r'\s+', word):
263
+ if not first_in_line:
264
+ current_line.append(TextSegment(word, segment.is_bold, segment.is_italic))
265
+ current_width += draw.textlength(word, font=font)
266
+ continue
267
+
268
+ word_width = draw.textlength(word, font=font)
269
+
270
+ if current_width + word_width <= max_width or first_in_line:
271
+ current_line.append(TextSegment(word, segment.is_bold, segment.is_italic))
272
+ current_width += word_width
273
+ first_in_line = False
274
+ else:
275
+ if current_line:
276
+ lines.append(current_line)
277
+ current_line = [TextSegment(word, segment.is_bold, segment.is_italic)]
278
+ current_width = word_width
279
+ first_in_line = False
280
+
281
+ if current_line:
282
+ lines.append(current_line)
283
+
284
+ return lines
285
+
286
+ def wrap_simple_text(text: str, font: ImageFont.FreeTypeFont, max_width: int,
287
+ draw: ImageDraw.Draw) -> List[str]:
288
+ words = text.split()
289
+ lines = []
290
+ current_line = []
291
+
292
+ for word in words:
293
+ test_line = ' '.join(current_line + [word])
294
+ if draw.textlength(test_line, font=font) <= max_width or not current_line:
295
+ current_line.append(word)
296
+ else:
297
+ if current_line:
298
+ lines.append(' '.join(current_line))
299
+ current_line = [word]
300
+
301
+ if current_line:
302
+ lines.append(' '.join(current_line))
303
+
304
+ return lines
305
+
306
+ def get_responsive_fonts(text: str, regular_font_path: str, bold_font_path: str,
307
+ italic_font_path: str, bold_italic_font_path: str,
308
+ max_width: int, max_lines: int, max_font_size: int, min_font_size: int,
309
+ draw: ImageDraw.Draw) -> Tuple[ImageFont.FreeTypeFont, ImageFont.FreeTypeFont,
310
+ ImageFont.FreeTypeFont, ImageFont.FreeTypeFont, List[List[TextSegment]], int]:
311
+ segments = parse_text_with_formatting(text)
312
+ current_font_size = max_font_size
313
+
314
+ while current_font_size >= min_font_size:
315
+ try:
316
+ fonts = [ImageFont.truetype(path, current_font_size)
317
+ for path in [regular_font_path, bold_font_path, italic_font_path, bold_italic_font_path]]
318
+ regular_font, bold_font, italic_font, bold_italic_font = fonts
319
+ except Exception:
320
+ fonts = [ImageFont.load_default()] * 4
321
+ regular_font, bold_font, italic_font, bold_italic_font = fonts
322
+
323
+ lines = wrap_text_with_formatting(segments, regular_font, bold_font, italic_font, bold_italic_font, max_width, draw)
324
+
325
+ if len(lines) <= max_lines:
326
+ return regular_font, bold_font, italic_font, bold_italic_font, lines, current_font_size
327
+
328
+ current_font_size -= 1
329
+
330
+ return regular_font, bold_font, italic_font, bold_italic_font, lines, min_font_size
331
+
332
+ def get_responsive_single_font(text: str, font_path: str, max_width: int, max_lines: int,
333
+ max_font_size: int, min_font_size: int, draw: ImageDraw.Draw,
334
+ format_text: bool = False) -> Tuple[ImageFont.FreeTypeFont, List[str], int]:
335
+ formatted_text = f"{text}" if format_text else text
336
+ current_font_size = max_font_size
337
+
338
+ while current_font_size >= min_font_size:
339
+ try:
340
+ font = ImageFont.truetype(font_path, current_font_size)
341
+ except Exception:
342
+ font = ImageFont.load_default()
343
+
344
+ lines = wrap_simple_text(formatted_text, font, max_width, draw)
345
+
346
+ if len(lines) <= max_lines:
347
+ return font, lines, current_font_size
348
+
349
+ current_font_size -= 2
350
+
351
+ return font, lines, min_font_size
352
+
353
+ def draw_formatted_line(draw: ImageDraw.Draw, line_segments: List[TextSegment],
354
+ x: int, y: int, regular_font: ImageFont.FreeTypeFont,
355
+ bold_font: ImageFont.FreeTypeFont, italic_font: ImageFont.FreeTypeFont,
356
+ bold_italic_font: ImageFont.FreeTypeFont, color: tuple = (255, 255, 255)):
357
+ current_x = x
358
+
359
+ for segment in line_segments:
360
+ font = get_font(segment, regular_font, bold_font, italic_font, bold_italic_font)
361
+ draw.text((current_x, y), segment.text, font=font, fill=color)
362
+ current_x += draw.textlength(segment.text, font=font)
363
+
364
+ def get_text_color_rgb(text_color: str) -> tuple[int, int, int]:
365
+ """
366
+ Converte o parâmetro text_color para RGB.
367
+ """
368
+ if text_color.lower() == "black":
369
+ return (0, 0, 0)
370
+ else: # white por padrão
371
+ return (255, 255, 255)
372
+
373
+ def get_device_dimensions() -> tuple[int, int]:
374
+ return (1080, 1350)
375
+
376
+ def add_logo(canvas: Image.Image):
377
+ try:
378
+ logo = Image.open("recurve.png").convert("RGBA")
379
+ logo_resized = logo.resize((121, 23))
380
+
381
+ logo_with_opacity = Image.new("RGBA", logo_resized.size)
382
+ for x in range(logo_resized.width):
383
+ for y in range(logo_resized.height):
384
+ r, g, b, a = logo_resized.getpixel((x, y))
385
+ logo_with_opacity.putpixel((x, y), (r, g, b, int(a * 0.42)))
386
+
387
+ canvas.paste(logo_with_opacity, (891, 1274), logo_with_opacity)
388
+ except Exception as e:
389
+ print(f"Aviso: Erro ao carregar a logo: {e}")
390
+
391
+ def create_canvas(image_url: Optional[Union[str, List[str]]], text: Optional[str], text_position: str = "bottom",
392
+ citation: Optional[str] = None, citation_direction: str = "bottom",
393
+ text_color: str = "white") -> BytesIO:
394
+ width, height = get_device_dimensions()
395
+ padding_x, top_padding, citation_text_gap = 60, 60, 15
396
+ max_width = width - 2 * padding_x
397
+ text_rgb = get_text_color_rgb(text_color)
398
+
399
+ # Validação das combinações de posições
400
+ if text:
401
+ valid_combinations = {
402
+ "top": ["text-bottom", "text-top", "bottom"],
403
+ "bottom": ["text-top", "text-bottom", "top"]
404
+ }
405
+
406
+ if citation and citation_direction not in valid_combinations.get(text_position, []):
407
+ raise HTTPException(
408
+ status_code=400,
409
+ detail=f"Combinação inválida: text_position='{text_position}' com citation_direction='{citation_direction}'. "
410
+ f"Para text_position='{text_position}', use citation_direction em: {valid_combinations.get(text_position, [])}"
411
+ )
412
+
413
+ canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255))
414
+
415
+ # Processar imagem(s) - suporte para colagem
416
+ if image_url:
417
+ parsed_urls = parse_image_urls(image_url)
418
+ if parsed_urls:
419
+ if len(parsed_urls) > 6:
420
+ raise HTTPException(status_code=400, detail="Máximo de 6 imagens permitidas")
421
+
422
+ if len(parsed_urls) == 1:
423
+ # Uma única imagem - comportamento original
424
+ img = download_image_from_url(parsed_urls[0])
425
+ filled_img = resize_and_crop_to_fill(img, width, height)
426
+ canvas.paste(filled_img, (0, 0))
427
+ else:
428
+ # Múltiplas imagens - criar colagem
429
+ canvas = create_collage_background(parsed_urls, width, height)
430
+
431
+ # Determinar posições do gradiente e se precisa expandir
432
+ # Só aplicar gradiente se o texto não for preto
433
+ gradient_positions = []
434
+ needs_expanded_gradient = False
435
+
436
+ if text_color.lower() != "black":
437
+ if text and text_position.lower() == "bottom":
438
+ gradient_positions.append("bottom")
439
+ # Se há citação com text-top, expande o gradiente bottom
440
+ if citation and citation_direction.lower() == "text-top":
441
+ needs_expanded_gradient = True
442
+ elif text and text_position.lower() == "top":
443
+ gradient_positions.append("top")
444
+ # Se há citação com text-bottom, expande o gradiente top
445
+ if citation and citation_direction.lower() == "text-bottom":
446
+ needs_expanded_gradient = True
447
+
448
+ # Adicionar gradientes para citações em posições fixas
449
+ if citation and citation_direction.lower() == "top" and "top" not in gradient_positions:
450
+ gradient_positions.append("top")
451
+ elif citation and citation_direction.lower() == "bottom" and "bottom" not in gradient_positions:
452
+ gradient_positions.append("bottom")
453
+
454
+ if gradient_positions:
455
+ gradient_overlay = create_gradient_overlay(width, height, gradient_positions, needs_expanded_gradient)
456
+ canvas = Image.alpha_composite(canvas, gradient_overlay)
457
+
458
+ add_logo(canvas)
459
+
460
+ if text or citation:
461
+ canvas_rgb = canvas.convert("RGB")
462
+ draw = ImageDraw.Draw(canvas_rgb)
463
+
464
+ font_paths = {
465
+ 'regular': "fonts/WorkSans-Regular.ttf",
466
+ 'bold': "fonts/WorkSans-SemiBold.ttf",
467
+ 'italic': "fonts/WorkSans-Italic.ttf",
468
+ 'bold_italic': "fonts/WorkSans-SemiBoldItalic.ttf",
469
+ 'citation': "fonts/AGaramondPro-Semibold.ttf"
470
+ }
471
+
472
+ text_lines, text_height, citation_lines, citation_height = [], 0, [], 0
473
+
474
+ if text:
475
+ try:
476
+ regular_font, bold_font, italic_font, bold_italic_font, text_lines, font_size = get_responsive_fonts(
477
+ text, font_paths['regular'], font_paths['bold'], font_paths['italic'],
478
+ font_paths['bold_italic'], max_width, 5, 35, 15, draw
479
+ )
480
+ text_height = len(text_lines) * int(font_size * 1.2)
481
+ except Exception:
482
+ text_lines = [[TextSegment(word, False, False) for word in text.split()]]
483
+ text_height = 40
484
+
485
+ if citation:
486
+ try:
487
+ citation_font, citation_lines, citation_font_size = get_responsive_single_font(
488
+ citation, font_paths['citation'], max_width, 3, 60, 30, draw, True
489
+ )
490
+ citation_height = len(citation_lines) * int(citation_font_size * 1.05)
491
+ except Exception:
492
+ citation_lines = [citation]
493
+ citation_height = 40
494
+
495
+ # Calcular posições respeitando os limites da imagem
496
+ text_y = citation_y = 0
497
+ bottom_limit = 1274 - 50 # Limite inferior (antes da logo)
498
+
499
+ if text and citation:
500
+ # Calcular espaço total necessário quando há texto e citação
501
+ total_gap = citation_text_gap if citation_direction in ["text-top", "text-bottom"] else 0
502
+ total_content_height = text_height + citation_height + total_gap
503
+
504
+ if citation_direction.lower() == "text-top":
505
+ # Citação acima do texto
506
+ if text_position.lower() == "bottom":
507
+ # Posicionar do bottom para cima
508
+ text_y = min(bottom_limit - text_height, bottom_limit - text_height)
509
+ citation_y = text_y - citation_text_gap - citation_height
510
+ # Verificar se vaza pelo topo
511
+ if citation_y < top_padding:
512
+ # Reajustar para caber tudo
513
+ available_height = bottom_limit - top_padding
514
+ if total_content_height <= available_height:
515
+ citation_y = top_padding
516
+ text_y = citation_y + citation_height + citation_text_gap
517
+ else: # text top
518
+ # Posicionar do top para baixo
519
+ citation_y = top_padding
520
+ text_y = citation_y + citation_height + citation_text_gap
521
+ # Verificar se vaza pelo bottom
522
+ if text_y + text_height > bottom_limit:
523
+ # Reajustar para caber tudo
524
+ available_height = bottom_limit - top_padding
525
+ if total_content_height <= available_height:
526
+ text_y = bottom_limit - text_height
527
+ citation_y = text_y - citation_text_gap - citation_height
528
+
529
+ elif citation_direction.lower() == "text-bottom":
530
+ # Citação abaixo do texto
531
+ if text_position.lower() == "bottom":
532
+ # Posicionar do bottom para cima
533
+ citation_y = bottom_limit - citation_height
534
+ text_y = citation_y - citation_text_gap - text_height
535
+ # Verificar se vaza pelo topo
536
+ if text_y < top_padding:
537
+ # Reajustar para caber tudo
538
+ available_height = bottom_limit - top_padding
539
+ if total_content_height <= available_height:
540
+ text_y = top_padding
541
+ citation_y = text_y + text_height + citation_text_gap
542
+ else: # text top
543
+ # Posicionar do top para baixo
544
+ text_y = top_padding
545
+ citation_y = text_y + text_height + citation_text_gap
546
+ # Verificar se vaza pelo bottom
547
+ if citation_y + citation_height > bottom_limit:
548
+ # Reajustar para caber tudo
549
+ available_height = bottom_limit - top_padding
550
+ if total_content_height <= available_height:
551
+ citation_y = bottom_limit - citation_height
552
+ text_y = citation_y - citation_text_gap - text_height
553
+
554
+ elif citation_direction.lower() == "top":
555
+ # Citação no topo, texto na posição original
556
+ citation_y = top_padding
557
+ if text_position.lower() == "bottom":
558
+ text_y = bottom_limit - text_height
559
+ else:
560
+ # Evitar sobreposição
561
+ text_y = max(top_padding + citation_height + citation_text_gap, top_padding)
562
+
563
+ elif citation_direction.lower() == "bottom":
564
+ # Citação no bottom, texto na posição original
565
+ citation_y = bottom_limit - citation_height
566
+ if text_position.lower() == "top":
567
+ text_y = top_padding
568
+ else:
569
+ # Evitar sobreposição
570
+ text_y = min(bottom_limit - text_height, citation_y - citation_text_gap - text_height)
571
+
572
+ elif text:
573
+ # Apenas texto, posições fixas originais
574
+ if text_position.lower() == "bottom":
575
+ text_y = bottom_limit - text_height
576
+ else:
577
+ text_y = top_padding
578
+
579
+ elif citation:
580
+ # Apenas citação
581
+ if citation_direction.lower() == "top":
582
+ citation_y = top_padding
583
+ elif citation_direction.lower() == "bottom":
584
+ citation_y = bottom_limit - citation_height
585
+
586
+ # Desenhar citação
587
+ if citation_lines:
588
+ line_height = int(citation_font_size * 1.05) if 'citation_font_size' in locals() else 40
589
+ for i, line in enumerate(citation_lines):
590
+ draw.text((padding_x, citation_y + i * line_height), line,
591
+ font=citation_font if 'citation_font' in locals() else ImageFont.load_default(),
592
+ fill=text_rgb)
593
+
594
+ # Desenhar texto
595
+ if text_lines:
596
+ line_height = int(font_size * 1.2) if 'font_size' in locals() else 40
597
+ for i, line in enumerate(text_lines):
598
+ if 'regular_font' in locals():
599
+ draw_formatted_line(draw, line, padding_x, text_y + i * line_height,
600
+ regular_font, bold_font, italic_font, bold_italic_font, text_rgb)
601
+ else:
602
+ draw.text((padding_x, text_y + i * line_height), ' '.join([s.text for s in line]),
603
+ font=ImageFont.load_default(), fill=text_rgb)
604
+
605
+ canvas = canvas_rgb.convert("RGBA")
606
+
607
+ buffer = BytesIO()
608
+ canvas.convert("RGB").save(buffer, format="PNG")
609
+ buffer.seek(0)
610
+ return buffer
611
+
612
+ def create_cover_canvas(image_url: Optional[Union[str, List[str]]], title: Optional[str], title_position: str = "bottom",
613
+ text_color: str = "white") -> BytesIO:
614
+ width, height = get_device_dimensions()
615
+ padding_x, top_padding = 60, 60
616
+ max_width = width - 2 * padding_x
617
+ text_rgb = get_text_color_rgb(text_color)
618
+
619
+ canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255))
620
+
621
+ # Processar imagem(s) - suporte para colagem
622
+ if image_url:
623
+ parsed_urls = parse_image_urls(image_url)
624
+ if parsed_urls:
625
+ if len(parsed_urls) > 6:
626
+ raise HTTPException(status_code=400, detail="Máximo de 6 imagens permitidas")
627
+
628
+ if len(parsed_urls) == 1:
629
+ # Uma única imagem - comportamento original
630
+ img = download_image_from_url(parsed_urls[0])
631
+ filled_img = resize_and_crop_to_fill(img, width, height)
632
+ canvas.paste(filled_img, (0, 0))
633
+ else:
634
+ # Múltiplas imagens - criar colagem
635
+ canvas = create_collage_background(parsed_urls, width, height)
636
+
637
+ gradient_positions = []
638
+ # Só aplicar gradiente se o texto não for preto
639
+ if title and text_color.lower() != "black":
640
+ gradient_positions.append(title_position.lower())
641
+
642
+ if gradient_positions:
643
+ gradient_overlay = create_gradient_overlay(width, height, gradient_positions)
644
+ canvas = Image.alpha_composite(canvas, gradient_overlay)
645
+
646
+ add_logo(canvas)
647
+
648
+ if title:
649
+ canvas_rgb = canvas.convert("RGB")
650
+ draw = ImageDraw.Draw(canvas_rgb)
651
+
652
+ try:
653
+ title_font, title_lines, title_font_size = get_responsive_single_font(
654
+ title, "fonts/AGaramondPro-Regular.ttf", max_width, 3, 85, 40, draw
655
+ )
656
+ title_line_height = int(title_font_size * 1.2)
657
+ title_height = len(title_lines) * title_line_height
658
+ except Exception:
659
+ title_font = ImageFont.load_default()
660
+ title_lines = [title]
661
+ title_line_height = title_height = 50
662
+
663
+ title_y = (1274 - 50 - title_height) if title_position.lower() == "bottom" else top_padding
664
+
665
+ for i, line in enumerate(title_lines):
666
+ draw.text((padding_x, title_y + i * title_line_height), line, font=title_font, fill=text_rgb)
667
+
668
+ canvas = canvas_rgb.convert("RGBA")
669
+
670
+ buffer = BytesIO()
671
+ canvas.convert("RGB").save(buffer, format="PNG")
672
+ buffer.seek(0)
673
+ return buffer
674
+
675
+ @router.get("/create/image")
676
+ def get_news_image(
677
+ image_url: Optional[Union[str, List[str]]] = Query(None, description="URL da imagem ou lista de URLs separadas por vírgula para colagem (máximo 6)"),
678
+ text: Optional[str] = Query(None, description="Texto com suporte a tags <strong>"),
679
+ text_position: str = Query("bottom", description="Posição do texto: 'top' para topo ou 'bottom' para parte inferior"),
680
+ citation: Optional[str] = Query(None, description="Texto da citação"),
681
+ citation_direction: str = Query("bottom", description="Posição da citação: 'top', 'bottom' ou 'text-top'"),
682
+ text_color: str = Query("white", description="Cor do texto: 'white' (padrão) ou 'black'. Se 'black', remove o gradiente de fundo")
683
+ ):
684
+ try:
685
+ buffer = create_canvas(image_url, text, text_position, citation, citation_direction, text_color)
686
+ return StreamingResponse(buffer, media_type="image/png")
687
+ except Exception as e:
688
+ raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}")
689
+
690
+ @router.get("/create/cover/image")
691
+ def get_cover_image(
692
+ image_url: Optional[Union[str, List[str]]] = Query(None, description="URL da imagem ou lista de URLs separadas por vírgula para colagem (máximo 6)"),
693
+ title: Optional[str] = Query(None, description="Título da capa"),
694
+ title_position: str = Query("bottom", description="Posição do título: 'top' para topo ou 'bottom' para parte inferior"),
695
+ text_color: str = Query("white", description="Cor do texto: 'white' (padrão) ou 'black'. Se 'black', remove o gradiente de fundo")
696
+ ):
697
+ try:
698
+ buffer = create_cover_canvas(image_url, title, title_position, text_color)
699
+ return StreamingResponse(buffer, media_type="image/png")
700
+ except Exception as e:
701
+ raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}")
routers/inference.py ADDED
@@ -0,0 +1,593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import json
4
+ import requests
5
+ import importlib.util
6
+ from pathlib import Path
7
+ from fastapi import APIRouter, HTTPException
8
+ from pydantic import BaseModel
9
+ from google import genai
10
+ from google.genai import types
11
+ from datetime import datetime
12
+ from zoneinfo import ZoneInfo
13
+ import locale
14
+ import re
15
+ import asyncio
16
+ from typing import Optional, Dict, Any
17
+
18
+ # Configurar logging
19
+ logger = logging.getLogger(__name__)
20
+
21
+ router = APIRouter()
22
+
23
+ class NewsRequest(BaseModel):
24
+ content: str
25
+ file_id: str = None # Agora opcional
26
+
27
+ class NewsResponse(BaseModel):
28
+ title: str
29
+ subhead: str
30
+ content: str
31
+ sources_info: Optional[Dict[str, Any]] = None # Informações das fontes geradas
32
+
33
+ # Referência ao diretório de arquivos temporários
34
+ TEMP_DIR = Path("/tmp")
35
+
36
+ def load_searchterm_module():
37
+ """Carrega o módulo searchterm.py dinamicamente"""
38
+ try:
39
+ # Procura o arquivo searchterm.py em diferentes locais
40
+ searchterm_path = Path(__file__).parent / "searchterm.py"
41
+
42
+ if not searchterm_path.exists():
43
+ # Tenta outros caminhos possíveis
44
+ possible_paths = [
45
+ Path(__file__).parent.parent / "searchterm.py",
46
+ Path("./searchterm.py"),
47
+ Path("../searchterm.py")
48
+ ]
49
+
50
+ for path in possible_paths:
51
+ if path.exists():
52
+ searchterm_path = path
53
+ break
54
+ else:
55
+ logger.error("searchterm.py não encontrado em nenhum dos caminhos")
56
+ return None
57
+
58
+ spec = importlib.util.spec_from_file_location("searchterm", searchterm_path)
59
+ searchterm_module = importlib.util.module_from_spec(spec)
60
+ spec.loader.exec_module(searchterm_module)
61
+
62
+ logger.info(f"Módulo searchterm.py carregado com sucesso: {searchterm_path}")
63
+ return searchterm_module
64
+ except Exception as e:
65
+ logger.error(f"Erro ao carregar searchterm.py: {str(e)}")
66
+ return None
67
+
68
+ # Carrega o módulo na inicialização
69
+ searchterm_module = load_searchterm_module()
70
+
71
+ async def generate_sources_from_content(content: str) -> Optional[str]:
72
+ """
73
+ Gera fontes usando o módulo searchterm baseado no conteúdo da notícia
74
+ """
75
+ try:
76
+ if not searchterm_module:
77
+ logger.error("Módulo searchterm não carregado")
78
+ return None
79
+
80
+ logger.info(f"Gerando fontes para conteúdo: {len(content)} caracteres")
81
+
82
+ # Prepara o payload para o searchterm
83
+ payload = {"context": content}
84
+
85
+ # Chama a função search_terms do módulo searchterm
86
+ # Simula uma requisição FastAPI criando um objeto com o método necessário
87
+ result = await searchterm_module.search_terms(payload)
88
+
89
+ if result and "file_info" in result:
90
+ file_id = result["file_info"]["file_id"]
91
+ logger.info(f"Fontes geradas com sucesso. File ID: {file_id}")
92
+ logger.info(f"Total de resultados: {result.get('total_results', 0)}")
93
+ logger.info(f"Termos gerados: {len(result.get('generated_terms', []))}")
94
+
95
+ return file_id
96
+ else:
97
+ logger.error("Resultado inválido do searchterm")
98
+ return None
99
+
100
+ except Exception as e:
101
+ logger.error(f"Erro ao gerar fontes: {str(e)}")
102
+ return None
103
+
104
+ def get_brazilian_date_string():
105
+ """
106
+ Retorna a data atual formatada em português brasileiro.
107
+ Implementa fallbacks robustos para diferentes sistemas operacionais.
108
+ """
109
+ try:
110
+ # Tenta configurar o locale brasileiro
111
+ locale_variants = [
112
+ 'pt_BR.UTF-8',
113
+ 'pt_BR.utf8',
114
+ 'pt_BR',
115
+ 'Portuguese_Brazil.1252',
116
+ 'Portuguese_Brazil',
117
+ 'pt_BR.ISO8859-1',
118
+ ]
119
+
120
+ locale_set = False
121
+ for loc in locale_variants:
122
+ try:
123
+ locale.setlocale(locale.LC_TIME, loc)
124
+ locale_set = True
125
+ break
126
+ except locale.Error:
127
+ continue
128
+
129
+ if not locale_set:
130
+ locale.setlocale(locale.LC_TIME, '')
131
+
132
+ now = datetime.now(ZoneInfo("America/Sao_Paulo"))
133
+
134
+ # Dicionários para tradução manual (fallback)
135
+ meses = {
136
+ 1: 'janeiro', 2: 'fevereiro', 3: 'março', 4: 'abril',
137
+ 5: 'maio', 6: 'junho', 7: 'julho', 8: 'agosto',
138
+ 9: 'setembro', 10: 'outubro', 11: 'novembro', 12: 'dezembro'
139
+ }
140
+
141
+ dias_semana = {
142
+ 0: 'segunda-feira', 1: 'terça-feira', 2: 'quarta-feira',
143
+ 3: 'quinta-feira', 4: 'sexta-feira', 5: 'sábado', 6: 'domingo'
144
+ }
145
+
146
+ try:
147
+ if locale_set:
148
+ try:
149
+ date_string = now.strftime("%-d de %B de %Y (%A)")
150
+ except ValueError:
151
+ try:
152
+ date_string = now.strftime("%#d de %B de %Y (%A)")
153
+ except ValueError:
154
+ date_string = now.strftime("%d de %B de %Y (%A)")
155
+ if date_string.startswith('0'):
156
+ date_string = date_string[1:]
157
+
158
+ date_string = date_string.replace(date_string.split('(')[1].split(')')[0],
159
+ date_string.split('(')[1].split(')')[0].lower())
160
+ else:
161
+ dia = now.day
162
+ mes = meses[now.month]
163
+ ano = now.year
164
+ dia_semana = dias_semana[now.weekday()]
165
+ date_string = f"{dia} de {mes} de {ano} ({dia_semana})"
166
+
167
+ except Exception:
168
+ dia = now.day
169
+ mes = meses[now.month]
170
+ ano = now.year
171
+ dia_semana = dias_semana[now.weekday()]
172
+ date_string = f"{dia} de {mes} de {ano} ({dia_semana})"
173
+
174
+ return date_string
175
+
176
+ except Exception:
177
+ now = datetime.now(ZoneInfo("America/Sao_Paulo"))
178
+ date_string = now.strftime("%d de %B de %Y")
179
+ return date_string
180
+
181
+ def load_sources_file(file_id: str) -> str:
182
+ """
183
+ Carrega o arquivo de fontes pelo ID do arquivo temporário.
184
+ """
185
+ try:
186
+ # Constrói o caminho do arquivo
187
+ file_path = TEMP_DIR / f"fontes_{file_id}.txt"
188
+
189
+ # Verifica se o arquivo existe
190
+ if not file_path.exists():
191
+ raise HTTPException(
192
+ status_code=404,
193
+ detail=f"Arquivo temporário não encontrado ou expirado: {file_id}"
194
+ )
195
+
196
+ # Lê o conteúdo do arquivo
197
+ with open(file_path, 'r', encoding='utf-8') as f:
198
+ file_content = f.read()
199
+
200
+ # Se for um JSON, extrai os dados; caso contrário, retorna o conteúdo direto
201
+ try:
202
+ data = json.loads(file_content)
203
+ # Se contém 'results', formata os dados para o Gemini
204
+ if 'results' in data and isinstance(data['results'], list):
205
+ formatted_content = ""
206
+ for idx, result in enumerate(data['results'], 1):
207
+ formatted_content += f"\n--- FONTE {idx} ---\n"
208
+ formatted_content += f"Termo: {result.get('term', 'N/A')}\n"
209
+ formatted_content += f"URL: {result.get('url', 'N/A')}\n"
210
+ formatted_content += f"Idade: {result.get('age', 'N/A')}\n"
211
+ formatted_content += f"Conteúdo:\n{result.get('text', 'N/A')}\n"
212
+ formatted_content += "-" * 50 + "\n"
213
+ return formatted_content
214
+ else:
215
+ return file_content
216
+ except json.JSONDecodeError:
217
+ # Se não for JSON válido, retorna o conteúdo como texto
218
+ return file_content
219
+
220
+ except FileNotFoundError:
221
+ raise HTTPException(
222
+ status_code=404,
223
+ detail=f"Arquivo temporário não encontrado: {file_id}"
224
+ )
225
+ except PermissionError:
226
+ raise HTTPException(
227
+ status_code=500,
228
+ detail=f"Erro de permissão ao acessar arquivo: {file_id}"
229
+ )
230
+ except Exception as e:
231
+ logger.error(f"Erro ao carregar arquivo de fontes {file_id}: {e}")
232
+ raise HTTPException(
233
+ status_code=500,
234
+ detail=f"Erro ao carregar arquivo de fontes: {str(e)}"
235
+ )
236
+
237
+ def extract_text_from_response(response):
238
+ """
239
+ Extrai o texto da resposta de forma robusta com debug.
240
+ """
241
+ logger.info(f"Tipo da resposta: {type(response)}")
242
+
243
+ # Método 1: Tentar acessar response.text diretamente
244
+ try:
245
+ text_content = getattr(response, 'text', None)
246
+ if text_content:
247
+ logger.info(f"Texto extraído via response.text: {len(text_content)} caracteres")
248
+ return text_content
249
+ else:
250
+ logger.info("response.text existe mas está vazio/None")
251
+ except Exception as e:
252
+ logger.error(f"Erro ao acessar response.text: {e}")
253
+
254
+ # Método 2: Verificar candidates
255
+ if hasattr(response, 'candidates') and response.candidates:
256
+ logger.info(f"Encontrados {len(response.candidates)} candidates")
257
+
258
+ for i, candidate in enumerate(response.candidates):
259
+ logger.info(f"Processando candidate {i}")
260
+
261
+ # Verificar se tem content
262
+ if hasattr(candidate, 'content') and candidate.content:
263
+ content = candidate.content
264
+ logger.info(f"Candidate {i} tem content")
265
+
266
+ # Verificar se tem parts
267
+ if hasattr(content, 'parts') and content.parts:
268
+ try:
269
+ parts_list = list(content.parts)
270
+ logger.info(f"Content tem {len(parts_list)} parts")
271
+
272
+ response_text = ""
273
+ for j, part in enumerate(parts_list):
274
+ logger.info(f"Processando part {j}, tipo: {type(part)}")
275
+
276
+ # Tentar várias formas de acessar o texto
277
+ part_text = None
278
+ if hasattr(part, 'text'):
279
+ part_text = getattr(part, 'text', None)
280
+
281
+ if part_text:
282
+ logger.info(f"Part {j} tem texto: {len(part_text)} caracteres")
283
+ response_text += part_text
284
+ else:
285
+ logger.info(f"Part {j} não tem texto ou está vazio")
286
+
287
+ if response_text:
288
+ return response_text
289
+ except Exception as e:
290
+ logger.error(f"Erro ao processar parts do candidate {i}: {e}")
291
+
292
+ return ""
293
+
294
+ @router.post("/rewrite-news", response_model=NewsResponse)
295
+ async def rewrite_news(news: NewsRequest):
296
+ """
297
+ Endpoint para reescrever notícias usando o modelo Gemini.
298
+ Se file_id não for fornecido, gera automaticamente as fontes usando o conteúdo.
299
+ """
300
+ try:
301
+ # Verificar API key
302
+ api_key = os.environ.get("GEMINI_API_KEY")
303
+ if not api_key:
304
+ raise HTTPException(status_code=500, detail="API key não configurada")
305
+
306
+ sources_info = None
307
+
308
+ # Se file_id não foi fornecido, gera fontes automaticamente
309
+ if not news.file_id:
310
+ logger.info("File ID não fornecido, gerando fontes automaticamente...")
311
+ generated_file_id = await generate_sources_from_content(news.content)
312
+
313
+ if generated_file_id:
314
+ news.file_id = generated_file_id
315
+ sources_info = {
316
+ "generated": True,
317
+ "file_id": generated_file_id,
318
+ "message": "Fontes geradas automaticamente a partir do conteúdo"
319
+ }
320
+ logger.info(f"Fontes geradas automaticamente. File ID: {generated_file_id}")
321
+ else:
322
+ logger.warning("Não foi possível gerar fontes automaticamente, prosseguindo sem fontes")
323
+ sources_info = {
324
+ "generated": False,
325
+ "message": "Não foi possível gerar fontes automaticamente"
326
+ }
327
+ else:
328
+ sources_info = {
329
+ "generated": False,
330
+ "file_id": news.file_id,
331
+ "message": "Usando file_id fornecido"
332
+ }
333
+
334
+ # Carregar arquivo de fontes se disponível
335
+ sources_content = ""
336
+ if news.file_id:
337
+ try:
338
+ sources_content = load_sources_file(news.file_id)
339
+ logger.info(f"Fontes carregadas: {len(sources_content)} caracteres")
340
+ except HTTPException as e:
341
+ logger.warning(f"Erro ao carregar fontes: {e.detail}")
342
+ sources_content = ""
343
+
344
+ client = genai.Client(api_key=api_key)
345
+ model = "gemini-2.5-pro"
346
+
347
+ # Obter data formatada
348
+ date_string = get_brazilian_date_string()
349
+
350
+ # Instruções do sistema (suas instruções originais aqui)
351
+ # Instruções do sistema
352
+ SYSTEM_INSTRUCTIONS = f"""
353
+ Você é um jornalista brasileiro, escrevendo para portais digitais. Sua missão é transformar notícias internacionais em matérias originais, detalhadas e atualizadas para o público brasileiro. Sempre use a notícia-base como ponto de partida, mas consulte o arquivo fontes.txt para extrair todas as informações relevantes, complementando fatos, contexto, dados e antecedentes. Não invente informações; na dúvida, não insira.
354
+ Seu estilo de escrita deve ser direto, claro e conversacional, sem jargões ou floreios desnecessários. Frases curtas e bem estruturadas, parágrafos segmentados para leitura digital e SEO. Evite repetições, clichês e generalizações.
355
+ Evite frases redundantes ou genéricas como:
356
+ - "Destacando como a experiência pode ser um divisor de águas profissional"
357
+ - "Reafirma a força criativa do país no cenário global"
358
+ - "A revelação contextualizou não apenas sua performance na dança, mas também"
359
+ - "A mudança visa estabelecer"
360
+ - "Além disso, a consolidação em X trará Y"
361
+
362
+ O conteúdo deve priorizar clareza, contexto e completude:
363
+ - Comece com a informação mais relevante e específica.
364
+ - Contextualize causas, consequências e conexões com outros acontecimentos.
365
+ - Inclua dados, datas, lançamentos e fontes confiáveis.
366
+ - Use citações, títulos de obras e nomes próprios quando pertinentes.
367
+ - Finalize sempre com fatos concretos, nunca com opinião genérica.
368
+
369
+ Nunca utilize as palavras "icônico" ou "icônicos" ao se referir a pessoas, acontecimentos ou objetos neste contexto. O jornal em questão é um veículo de direita, com um público majoritariamente conservador, e esse termo pode soar inadequado ou destoar do tom editorial esperado.
370
+ Em vez disso, prefira sempre sinônimos como "lendário", "lendários", "memorável", "histórico" ou outros termos que transmitam grandeza e relevância, mas mantendo a coerência com a linha editorial conservadora.
371
+
372
+ Formato da matéria:
373
+ <headline>título aqui</headline>
374
+ <subhead>subtítulo aqui</subhead>
375
+ <body>conteúdo aqui</body>
376
+
377
+ Use <strong> para destaques e <em> para títulos de obras, etc. Também inclua a tag [wiki] para termos que estão presentes na wikipédia. Bote wiki apenas em nomes completos, nomes de filmes, etc... Nunca em apelidos, primeiro nome e tudo mais.
378
+ Padrão de títulos: apenas a primeira palavra em maiúscula (exceto nomes próprios ou títulos de obras).
379
+ O resultado deve soar como uma reportagem profissional, completa, confiável e envolvente, no estilo da Fox News: informativa, detalhada, mantendo linguagem humana e acessível. Sempre busque complementar a notícia com informações adicionais e contextos do arquivo fontes.txt, evitando opiniões pessoais. Mantenha tom informativo e predominantemente conservador (o jornal tem um público maior da ala da Direita/Republicana, sem juízos de valor ou opiniões pessoais. Não use linguagem woke, de vitimização ou que force interpretações ideológicas.
380
+ A data de hoje é {date_string}.
381
+
382
+ """
383
+
384
+ # Exemplos
385
+ EXAMPLE_INPUT_1 = """
386
+ Observação: O Jornal mantém um público majoritário de maioria Republicana americana/Direita brasileira, então é um jornal conservador. Escreva a notícia ciente disso. 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.
387
+ """
388
+
389
+ 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>
390
+ <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>
391
+ <body>
392
+ <p>[wiki]<strong>Ed Helms</strong>[/wiki] nunca escondeu o fato de que sua participação em [wiki]<strong>Se Beber, Não Case!</strong>[/wiki] foi um choque cultural, especialmente para seus pais. Em uma entrevista recente ao podcast de [wiki]<strong>Ted Danson</strong>[/wiki], <em>[wiki]Where Everybody Knows Your Name[/wiki]</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>
393
+ <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>[wiki]The Daily Show[/wiki]</em> e <em>[wiki]The Office[/wiki]</em>, o que ajudou a criar certa tolerância, o filme ainda o deixava nervoso.</p>
394
+ <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>
395
+ <p>[wiki]<strong>Se Beber, Não Case!</strong>[/wiki], dirigido por <strong>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>, [wiki]<strong>Bradley Cooper</strong>[/wiki] e [wiki]<strong>Zach Galifianakis</strong>[/wiki] como um dos times cômicos mais icônicos do cinema moderno.</p>
396
+ <p>Sobre a possibilidade de um quarto filme, [wiki]<strong>Bradley Cooper</strong>[/wiki] 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>[wiki]Coringa[/wiki]</em>.</p>
397
+ </body>
398
+ """
399
+ EXAMPLE_INPUT_2 = """
400
+ Observação: O Jornal mantém um público majoritário de maioria Republicana americana/Direita brasileira, então é um jornal conservador. Escreva a notícia ciente disso. News base: The Office spinoff series 'The Paper' has set a September premiere date at Peacock.
401
+ 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.
402
+ '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.
403
+ '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.
404
+ Guest stars for the show include Eric Rahill, Tracy Letts, Molly Ephraim, Mo Welch, Allan Havey, Duane Shepard Sr., Nate Jackson and Nancy Lenehan.
405
+ '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).
406
+ 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.
407
+ '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.
408
+ """
409
+
410
+ EXAMPLE_OUTPUT_2 = """<headline>Nova série do universo 'The Office' ganha título, data de estreia e um rosto familiar</headline>
411
+ <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>
412
+ <body>
413
+ <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 [wiki]<em>"The Office"</em>[/wiki]. 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 é [wiki]<em>"The Paper"</em>[/wiki] e estará disponível na plataforma de streaming [wiki]<strong>Peacock</strong>[/wiki].</p>
414
+ <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 [wiki]<strong>Michael Scott</strong>[/wiki] e [wiki]<strong>Dwight Schrute</strong>[/wiki]. Agora, a equipe acompanha o <strong>Toledo Truth Teller</strong>, um jornal em [wiki]<strong>Toledo, Ohio</strong>[/wiki], e o editor que tenta reviver o jornal com a ajuda de repórteres voluntários.</p>
415
+ <p>O novo elenco conta com [wiki]<strong>Domhnall Gleeson</strong>[/wiki], ator irlandês famoso por [wiki]<em>"Ex Machina"</em>[/wiki] e [wiki]<em>"Questão de Tempo"</em>[/wiki], ao lado da atriz italiana [wiki]<strong>Sabrina Impacciatore</strong>[/wiki], que ganhou amplo reconhecimento por seu papel na segunda temporada de [wiki]<em>"The White Lotus"</em>[/wiki]. Gleeson interpreta o novo editor otimista do jornal, enquanto Impacciatore atua como gerente de redação.</p>
416
+ <p>Nas entrevistas mais recentes, Gleeson tenta se distanciar das comparações com o gerente da [wiki]<strong>Dunder Mifflin</strong>[/wiki]. <em>"Acho que se você tentar competir com o que [wiki]Steve Carell[/wiki] ou [wiki]Ricky Gervais[/wiki] 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 [wiki]<strong>John Krasinski</strong>[/wiki] e até de [wiki]<strong>Steve Carell</strong>[/wiki] para aceitar o papel, especialmente porque se tratava de um projeto de [wiki]<strong>Greg Daniels</strong>[/wiki].</p>
417
+ <p>Como [wiki]<em>"The Paper"</em>[/wiki] está reintroduzindo os personagens originais, os fãs de longa data da série parecem estar encantados, já que também traz [wiki]<strong>Oscar Nuñez</strong>[/wiki] reprisando seu papel como o contador <strong>Oscar Martinez</strong>. Oscar, que estava iniciando uma carreira política em [wiki]<em>"The Office"</em>[/wiki], agora parece ter se mudado para <strong>Toledo</strong>. <em>"Eu disse ao Sr. [wiki]<strong>Greg Daniels</strong>[/wiki] que, se Oscar voltasse, ele provavelmente estaria morando em uma cidade mais agitada e cosmopolita. Greg me ouviu e mudou Oscar para [wiki]<strong>Toledo, Ohio</strong>[/wiki], que tem três vezes a população de Scranton. Então, foi bom ser ouvido"</em>, brincou Nuñez durante um evento da [wiki]<strong>NBCUniversal</strong>[/wiki].</p>
418
+ <p>[wiki]<strong>Greg Daniels</strong>[/wiki], que anteriormente adaptou [wiki]<em>"The Office"</em>[/wiki] para o público americano, está em parceria com [wiki]<strong>Michael Koman</strong>[/wiki], cocriador de [wiki]<em>"Nathan for You"</em>[/wiki], para este novo projeto. Koman e Daniels, junto com [wiki]<strong>Ricky Gervais</strong>[/wiki] e [wiki]<strong>Stephen Merchant</strong>[/wiki], criadores da série britânica original, formam a equipe de produção executiva.</p>
419
+ <p>A primeira temporada de [wiki]<em>"The Paper"</em>[/wiki] 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>
420
+ <p>A série ainda não tem data de estreia confirmada no Brasil, mas a expectativa é de que seja lançada no [wiki]<strong>Universal+</strong>[/wiki], serviço de streaming que costuma exibir produções do catálogo da [wiki]<strong>Peacock</strong>[/wiki].</p>
421
+ </body>
422
+ """
423
+
424
+ EXAMPLE_INPUT_3 = """
425
+ Observação: O Jornal mantém um público majoritário de maioria Republicana americana/Direita brasileira, então é um jornal conservador. Escreva a notícia ciente disso. News base: Noah Centineo Attached to Play Rambo in Prequel Movie 'John Rambo'
426
+ """
427
+
428
+ EXAMPLE_OUTPUT_3 = """<headline>Noah Centineo é o novo Rambo em filme que contará a origem do personagem</headline>
429
+ <subhead>Ator de 'Para Todos os Garotos que Já Amei' assume o papel de Sylvester Stallone em prelúdio que se passará na Guerra do Vietnã</subhead>
430
+ <body>
431
+ <p>De acordo com a [wiki]<strong>Millennium Media</strong>[/wiki], [wiki]<strong>Noah Centineo</strong>[/wiki] foi escolhido para interpretar uma versão mais jovem de John Rambo no filme que contará os primórdios do lendário personagem. A produção, que é simplesmente chamada [wiki]<em>John Rambo</em>[/wiki], tenta examinar os primeiros anos do soldado antes dos eventos de [wiki]<em>First Blood</em>[/wiki] (1982).</p>
432
+ <p>[wiki]<strong>Jalmari Helander</strong>[/wiki], diretor finlandês mais conhecido pelo blockbuster de ação [wiki]<em>Sisu</em>[/wiki], comandará o filme. [wiki]<strong>Rory Haines</strong>[/wiki] e [wiki]<strong>Sohrab Noshirvani</strong>[/wiki], que trabalharam juntos em [wiki]<em>Black Adam</em>[/wiki], estão cuidando do roteiro. As filmagens na Tailândia estão previstas para começar no início de 2026.</p>
433
+ <p>A história se passará durante a [wiki]Guerra do Vietnã[/wiki], embora os detalhes da trama estejam sendo mantidos em sigilo. O objetivo é retratar a metamorfose de John Rambo. Antes da guerra, ele era "o cara perfeito, o mais popular da escola, um superatleta", como [wiki]<strong>Sylvester Stallone</strong>[/wiki] afirmou em 2019. Espera-se que o filme examine os eventos horríveis que o moldaram no veterano atormentado retratado no primeiro filme.</p>
434
+ <p>Embora não esteja diretamente envolvido no projeto, [wiki]<strong>Sylvester Stallone</strong>[/wiki], que interpretou o personagem em cinco filmes, está ciente dele. Segundo pessoas próximas à produção, ele foi informado sobre a escolha de Centineo. O ator, hoje com 79 anos, brincou em 2023 sobre a possibilidade de voltar a interpretar o papel, dizendo: "Ele já fez praticamente tudo. O que eu vou combater? Artrite?"</p>
435
+ <p>A escolha de Centineo, de 29 anos, marca uma nova fase na carreira do ator, que conquistou fama internacional com comédias românticas da Netflix, como a trilogia [wiki]<em>Para Todos os Garotos que Já Amei</em>[/wiki]. Nos últimos anos, porém, ele vem explorando o gênero de ação, interpretando o herói [wiki]<em>Esmaga-Átomo</em>[/wiki] em [wiki]<em>Adão Negro</em>[/wiki] e estrelando a série de espionagem [wiki]<em>O Recruta</em>[/wiki]. Recentemente, Centineo também esteve no drama de guerra [wiki]<em>Warfare</em>[/wiki], da [wiki]<strong>A24</strong>[/wiki], e está escalado para viver [wiki]<strong>Ken Masters</strong>[/wiki] no próximo filme de [wiki]<em>Street Fighter</em>[/wiki].</p>
436
+ <p>A franquia [wiki]<em>Rambo</em>[/wiki], baseada no livro [wiki]<em>First Blood</em>[/wiki], de [wiki]<strong>David Morrell</strong>[/wiki], é uma das mais conhecidas do cinema de ação. Os cinco filmes arrecadaram mais de 850 milhões de dólares em todo o mundo. Enquanto as sequências apostaram em ação em grande escala, o primeiro longa se destaca pelo tom mais solene e pela crítica ao tratamento dado aos veteranos do Vietnã.</p>
437
+ <p>A produção do novo filme está a cargo de [wiki]<strong>Avi Lerner</strong>[/wiki], [wiki]<strong>Jonathan Yunger</strong>[/wiki], [wiki]<strong>Les Weldon</strong>[/wiki] e [wiki]<strong>Kevin King-Templeton</strong>[/wiki]. A [wiki]<strong>Lionsgate</strong>[/wiki], que distribuiu os dois últimos longas da série, é a principal candidata a adquirir os direitos de distribuição do projeto.</p>
438
+ </body>
439
+ """
440
+ EXAMPLE_INPUT_4 = """
441
+ Observação: O Jornal mantém um público majoritário de maioria Republicana americana/Direita brasileira, então é um jornal conservador. Escreva a notícia ciente disso. News base: Sylvester Stallone, Gloria Gaynor, Kiss Set for Kennedy Center Honors Amid Trump Overhaul
442
+ The first honorees for the revamped Kennedy Center Honors have been unveiled by U.S. President Donald Trump, who is also the new chairman of the John F. Kennedy Center for the Performing Arts.
443
+ This year’s honorees include Rocky star and filmmaker Sylvester Stallone; disco-era singer Gloria Gaynor; the rock band Kiss; Michael Crawford, the British star of Phantom of the Opera; and country crooner and songwriter George Strait.
444
+ The 48th annual Kennedy Center Honors, set to air on the CBS network and stream on Paramount+, will be hosted by the U.S. President. Stallone was earlier named by Trump as one of his ambassadors to Hollywood. “He’s a very special guy. A real talent, never been given credit for the talent,” Trump added about the Hollywood actor during an hourlong press conference Wednesday
445
+ """
446
+
447
+ EXAMPLE_OUTPUT_4 = """<headline>Stallone, Kiss e Gloria Gaynor são os novos homenageados do Kennedy Center em premiação reformulada por Trump</headline>
448
+ <subhead>Presidente assume o comando do Kennedy Center, anuncia os homenageados pessoalmente e prioriza foco nas artes em vez de política ideológica</subhead>
449
+ <body>
450
+ <p>O presidente [wiki]<strong>Donald Trump</strong>[/wiki], agora à frente do conselho do [wiki]<strong>John F. Kennedy Center for the Performing Arts</strong>[/wiki], anunciou pessoalmente os homenageados de 2025 do prestigiado prêmio cultural. Os escolhidos são o ator e cineasta [wiki]<strong>Sylvester Stallone</strong>[/wiki], a banda de rock [wiki]<strong>Kiss</strong>[/wiki], a cantora [wiki]<strong>Gloria Gaynor</strong>[/wiki], o astro da música country [wiki]<strong>George Strait</strong>[/wiki] e o ator britânico [wiki]<strong>Michael Crawford</strong>[/wiki], conhecido por seu papel em [wiki]<em>O Fantasma da Ópera</em>[/wiki].</p>
451
+ <p>A cerimônia, que será a 48ª edição do evento, será transmitida pela CBS e pela plataforma [wiki]<strong>Paramount+</strong>[/wiki] e ocorrerá em [wiki]<strong>Washington, D.C.</strong>[/wiki], no dia 7 de dezembro. Em um evento amplamente divulgado, Trump anunciou os homenageados durante uma coletiva de imprensa na quarta-feira, contrariando o costume de divulgar os nomes por comunicado. Ele afirmou ter se envolvido “cerca de 98%” no processo de seleção e que rejeitou alguns nomes por serem “demasiado woke”.</p>
452
+ <p>A alteração, na verdade, representa o início de uma nova fase para o [wiki]<strong>Kennedy Center</strong>[/wiki]. Depois de reassumir a presidência, Trump trocou os indicados de administrações passadas por novos integrantes comprometidos em reorientar o centro. Ele afirmou que seu objetivo é reverter o que considera ser a "programação política 'woke'" e, em tom de brincadeira, sugeriu que poderia receber uma homenagem no ano seguinte.</p>
453
+ <p>Trump se absteve de comparecer a qualquer cerimônia no [wiki]<strong>Kennedy Center</strong>[/wiki] durante seu primeiro mandato, depois que vários artistas, incluindo o produtor [wiki]<strong>Norman Lear</strong>[/wiki], ameaçaram boicotar o evento em protesto. Agora, além de supervisionar as escolhas, será o anfitrião da gala.</p>
454
+ <p>Os homenageados representam, em certa medida, os interesses pessoais do presidente. [wiki]<strong>Sylvester Stallone</strong>[/wiki], amigo e apoiador de longa data, o descreveu como “um segundo [wiki]George Washington[/wiki]”. Em janeiro, Trump o nomeou, juntamente com [wiki]<strong>Mel Gibson</strong>[/wiki] e [wiki]<strong>Jon Voight</strong>[/wiki], como “embaixador especial” de Hollywood. [wiki]<strong>Michael Crawford</strong>[/wiki] foi o protagonista de [wiki]<em>O Fantasma da Ópera</em>[/wiki], um dos musicais preferidos do presidente.</p>
455
+ <p>A seleção da banda [wiki]<strong>Kiss</strong>[/wiki] traz um contexto um pouco mais complicado. Embora [wiki]<strong>Ace Frehley</strong>[/wiki], um dos membros fundadores, tenha apoiado Trump, outros integrantes, como [wiki]<strong>Paul Stanley</strong>[/wiki] e [wiki]<strong>Gene Simmons</strong>[/wiki], já expressaram críticas ao presidente em ocasiões anteriores. Simmons, ex-participante do reality show [wiki]<em>O Aprendiz</em>[/wiki], declarou em 2022 que Trump "não é republicano nem democrata. Ele está em causa própria."</p>
456
+ <p>A nova gestão do [wiki]<strong>Kennedy Center</strong>[/wiki] já provocou respostas no cenário artístico. Os organizadores do musical [wiki]<em>Hamilton</em>[/wiki] cancelaram a apresentação da turnê nacional no local, e outros artistas, como a atriz [wiki]<strong>Issa Rae</strong>[/wiki] e a produtora [wiki]<strong>Shonda Rhimes</strong>[/wiki], também romperam relações com a instituição em protesto. Em contrapartida, o anúncio dos homenageados gerou tanto interesse que o site oficial do [wiki]<strong>Kennedy Center</strong>[/wiki] ficou temporariamente fora do ar devido ao grande volume de tráfego.</p>
457
+ <p>Estabelecido em 1978, o [wiki]<strong>Kennedy Center Honors</strong>[/wiki] possui uma tradição bipartidária, congregando presidentes de diversos partidos para homenagear artistas notáveis de todos os gêneros e estilos.</p>
458
+ </body>
459
+ """
460
+ config = types.GenerateContentConfig(
461
+ system_instruction=SYSTEM_INSTRUCTIONS,
462
+ thinking_config=types.ThinkingConfig(
463
+ thinking_budget=2500,
464
+ ),
465
+ response_mime_type="text/plain",
466
+ max_output_tokens=16000,
467
+ temperature=1,
468
+ )
469
+
470
+ # Conteúdo da conversa
471
+ contents = [
472
+ # Primeiro exemplo
473
+ types.Content(
474
+ role="user",
475
+ parts=[
476
+ types.Part.from_text(text=EXAMPLE_INPUT_1)
477
+ ]
478
+ ),
479
+ types.Content(
480
+ role="model",
481
+ parts=[
482
+ types.Part.from_text(text=EXAMPLE_OUTPUT_1)
483
+ ]
484
+ ),
485
+ # Segundo exemplo
486
+ types.Content(
487
+ role="user",
488
+ parts=[
489
+ types.Part.from_text(text=EXAMPLE_INPUT_2)
490
+ ]
491
+ ),
492
+ types.Content(
493
+ role="model",
494
+ parts=[
495
+ types.Part.from_text(text=EXAMPLE_OUTPUT_2)
496
+ ]
497
+ ),
498
+ # Terceiro exemplo
499
+ types.Content(
500
+ role="user",
501
+ parts=[
502
+ types.Part.from_text(text=EXAMPLE_INPUT_3)
503
+ ]
504
+ ),
505
+ types.Content(
506
+ role="model",
507
+ parts=[
508
+ types.Part.from_text(text=EXAMPLE_OUTPUT_3)
509
+ ]
510
+ ),
511
+ # Quarto exemplo
512
+ types.Content(
513
+ role="user",
514
+ parts=[
515
+ types.Part.from_text(text=EXAMPLE_INPUT_4)
516
+ ]
517
+ ),
518
+ types.Content(
519
+ role="model",
520
+ parts=[
521
+ types.Part.from_text(text=EXAMPLE_OUTPUT_4)
522
+ ]
523
+ ),
524
+ # Notícia atual com arquivo de fontes
525
+ types.Content(
526
+ role="user",
527
+ parts=[
528
+ types.Part.from_text(text=f"News base: {news.content}. The article is out of date, more information is needed."),
529
+ types.Part.from_text(text=f"Fontes adicionais disponíveis:\n\n{sources_content}")
530
+ ]
531
+ )
532
+ ]
533
+
534
+ # Gerar conteúdo
535
+ response = client.models.generate_content(
536
+ model=model,
537
+ contents=contents,
538
+ config=config
539
+ )
540
+
541
+ # Gerar conteúdo
542
+ response = client.models.generate_content(
543
+ model=model,
544
+ contents=contents,
545
+ config=config
546
+ )
547
+
548
+ logger.info("Resposta do modelo recebida com sucesso")
549
+
550
+ # Extrair texto
551
+ response_text = extract_text_from_response(response)
552
+
553
+ logger.info(f"Texto extraído: {len(response_text) if response_text else 0} caracteres")
554
+
555
+ # Verificar se o texto está vazio
556
+ if not response_text or response_text.strip() == "":
557
+ logger.error("Texto extraído está vazio")
558
+ raise HTTPException(
559
+ status_code=500,
560
+ detail="Modelo não retornou conteúdo válido"
561
+ )
562
+
563
+ # Extração do título, subtítulo, conteúdo e campos do Instagram
564
+ title_match = re.search(r"<headline>(.*?)</headline>", response_text, re.DOTALL)
565
+ title = title_match.group(1).strip() if title_match else "Título não encontrado"
566
+
567
+ subhead_match = re.search(r"<subhead>(.*?)</subhead>", response_text, re.DOTALL)
568
+ subhead = subhead_match.group(1).strip() if subhead_match else "Subtítulo não encontrado"
569
+
570
+ body_match = re.search(r"<body>(.*?)</body>", response_text, re.DOTALL)
571
+ if body_match:
572
+ content = body_match.group(1).strip()
573
+ else:
574
+ body_start_match = re.search(r"<body>(.*)", response_text, re.DOTALL)
575
+ if body_start_match:
576
+ content = body_start_match.group(1).strip()
577
+ else:
578
+ content = "Conteúdo não encontrado"
579
+
580
+ logger.info(f"Processamento concluído com sucesso - Título: {title[:50]}...")
581
+
582
+ return NewsResponse(
583
+ title=title,
584
+ subhead=subhead,
585
+ content=content,
586
+ sources_info=sources_info
587
+ )
588
+
589
+ except HTTPException:
590
+ raise
591
+ except Exception as e:
592
+ logger.error(f"Erro na reescrita: {str(e)}")
593
+ raise HTTPException(status_code=500, detail=str(e))
routers/inference_createposter.py ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import re
4
+ import json
5
+ from urllib.parse import urlencode, quote, parse_qs, urlparse, urlunparse
6
+ from fastapi import APIRouter, HTTPException
7
+ from pydantic import BaseModel
8
+ from google import genai
9
+ from google.genai import types
10
+
11
+ # Configurar logging
12
+ logger = logging.getLogger(__name__)
13
+
14
+ router = APIRouter()
15
+
16
+ class PosterRequest(BaseModel):
17
+ content: str
18
+
19
+ class PosterResponse(BaseModel):
20
+ result: dict
21
+ urls: list
22
+
23
+ def clean_json_string(json_string: str) -> str:
24
+ """
25
+ Remove caracteres de controle inválidos do JSON antes do parse.
26
+ """
27
+ if not json_string:
28
+ return json_string
29
+
30
+ # Remove caracteres de controle (exceto \t, \n, \r que são válidos em JSON)
31
+ # mas precisamos escapar corretamente dentro das strings
32
+ cleaned = ""
33
+ i = 0
34
+ in_string = False
35
+ escape_next = False
36
+
37
+ while i < len(json_string):
38
+ char = json_string[i]
39
+
40
+ if escape_next:
41
+ # Se o caractere anterior foi \, adiciona este caractere escapado
42
+ cleaned += char
43
+ escape_next = False
44
+ elif char == '\\' and in_string:
45
+ # Caractere de escape dentro de string
46
+ cleaned += char
47
+ escape_next = True
48
+ elif char == '"' and not escape_next:
49
+ # Início ou fim de string (se não estiver escapado)
50
+ cleaned += char
51
+ in_string = not in_string
52
+ elif in_string:
53
+ # Dentro de string - tratar caracteres especiais
54
+ if ord(char) < 32 and char not in ['\t']: # Remove controles exceto tab
55
+ if char == '\n':
56
+ cleaned += '\\n' # Escapa quebra de linha
57
+ elif char == '\r':
58
+ cleaned += '\\r' # Escapa carriage return
59
+ else:
60
+ # Remove outros caracteres de controle
61
+ pass
62
+ else:
63
+ cleaned += char
64
+ else:
65
+ # Fora de string - remove apenas caracteres de controle problemáticos
66
+ if ord(char) >= 32 or char in ['\t', '\n', '\r', ' ']:
67
+ cleaned += char
68
+
69
+ i += 1
70
+
71
+ return cleaned
72
+
73
+ def fix_citation_quotes(citation_text: str) -> str:
74
+ """
75
+ Corrige as aspas no texto de citação:
76
+ - Se não tiver aspas no início e fim, adiciona " "
77
+ - Se tiver aspas comuns ou outras, substitui por " "
78
+ - Remove todas as tags HTML
79
+ """
80
+ if not citation_text or citation_text.strip() == "":
81
+ return citation_text
82
+
83
+ text = citation_text.strip()
84
+
85
+ # Remover todas as tags HTML
86
+ text = re.sub(r'<[^>]+>', '', text)
87
+
88
+ # Verificar se já tem as aspas corretas
89
+ if text.startswith('“') and text.endswith('”'):
90
+ return text
91
+
92
+ # Remover aspas existentes do início e fim
93
+ quote_chars = ['"', "'", '"', '"', ''', ''', '❝', '❞']
94
+
95
+ # Remover aspas do início
96
+ while text and text[0] in quote_chars:
97
+ text = text[1:]
98
+
99
+ # Remover aspas do fim
100
+ while text and text[-1] in quote_chars:
101
+ text = text[:-1]
102
+
103
+ # Adicionar as aspas corretas
104
+ return f"“{text.strip()}”"
105
+
106
+ def clean_text_content_for_text_param(text: str) -> str:
107
+ """
108
+ Limpa o conteúdo do parâmetro 'text':
109
+ - Remove apenas tags <wiki>
110
+ - Mantém <strong> e <em>
111
+ - Se tiver tags aninhadas (ex: <strong><em>), prioriza a segunda (mais interna)
112
+ """
113
+ if not text:
114
+ return text
115
+
116
+ # Primeiro, resolver conflitos de tags aninhadas - priorizar a segunda (mais interna)
117
+ # <strong><em>conteúdo</em></strong> -> <em>conteúdo</em>
118
+ text = re.sub(r'<strong>\s*<em>(.*?)</em>\s*</strong>', r'<em>\1</em>', text)
119
+ # <em><strong>conteúdo</strong></em> -> <strong>conteúdo</strong>
120
+ text = re.sub(r'<em>\s*<strong>(.*?)</strong>\s*</em>', r'<strong>\1</strong>', text)
121
+
122
+ # Remover apenas tags <wiki>
123
+ text = re.sub(r'</?wiki[^>]*>', '', text)
124
+
125
+ return text.strip()
126
+
127
+ def clean_text_content_remove_all_tags(text: str) -> str:
128
+ """
129
+ Remove TODAS as tags HTML do texto (para headline, title, citation).
130
+ Mantém apenas o conteúdo textual limpo.
131
+ """
132
+ if not text:
133
+ return text
134
+
135
+ # Remove TODAS as tags HTML usando regex mais ampla
136
+ text = re.sub(r'<[^>]*>', '', text)
137
+
138
+ # Remove possíveis entidades HTML comuns
139
+ text = text.replace('&lt;', '<').replace('&gt;', '>').replace('&amp;', '&')
140
+ text = text.replace('&quot;', '"').replace('&#39;', "'")
141
+
142
+ return text.strip()
143
+
144
+ def clean_text_content(text: str) -> str:
145
+ """
146
+ Função mantida para compatibilidade com o código existente.
147
+ Limpa o conteúdo de texto removendo tags inválidas e corrigindo formatação:
148
+ - Remove todas as tags exceto <strong> e <em>
149
+ - Se tiver <strong><em> juntas, prioriza <em>
150
+ - Se tiver <em><strong> juntas, prioriza <strong>
151
+ """
152
+ return clean_text_content_for_text_param(text)
153
+
154
+ def fix_url_citation(url: str) -> str:
155
+ """
156
+ Analisa uma URL e trata os parâmetros de texto de forma específica:
157
+ - Para 'text': mantém <strong> e <em>, remove <wiki>, resolve conflitos de tags aninhadas
158
+ - Para 'headline', 'title', 'citation': remove TODAS as tags HTML
159
+ """
160
+ try:
161
+ # Parse da URL
162
+ parsed_url = urlparse(url)
163
+ query_params = parse_qs(parsed_url.query)
164
+
165
+ # Parâmetros que devem ter TODAS as tags removidas
166
+ clean_all_params = ['headline', 'title', 'citation']
167
+
168
+ # Parâmetros que têm tratamento especial (apenas text)
169
+ special_text_params = ['text']
170
+
171
+ # Processar parâmetros que devem ser completamente limpos
172
+ for param in clean_all_params:
173
+ if param in query_params and query_params[param]:
174
+ original_text = query_params[param][0]
175
+ cleaned_text = clean_text_content_remove_all_tags(original_text)
176
+
177
+ # Se for citation, aplicar correção específica das aspas
178
+ if param == 'citation':
179
+ cleaned_text = fix_citation_quotes(cleaned_text)
180
+
181
+ query_params[param] = [cleaned_text]
182
+
183
+ # Processar parâmetro 'text' com tratamento especial
184
+ for param in special_text_params:
185
+ if param in query_params and query_params[param]:
186
+ original_text = query_params[param][0]
187
+ cleaned_text = clean_text_content_for_text_param(original_text)
188
+ query_params[param] = [cleaned_text]
189
+
190
+ # Reconstruir a query string
191
+ new_query = urlencode(
192
+ {k: v[0] if isinstance(v, list) and len(v) == 1 else v for k, v in query_params.items()},
193
+ quote_via=quote
194
+ )
195
+
196
+ # Reconstruir a URL
197
+ new_parsed_url = parsed_url._replace(query=new_query)
198
+ return urlunparse(new_parsed_url)
199
+
200
+ except Exception as e:
201
+ logger.warning(f"Erro ao processar URL para correção de texto: {e}")
202
+ return url
203
+
204
+ def format_url(base_url: str, endpoint: str, params: dict) -> str:
205
+ """
206
+ Formata uma URL completa com os parâmetros dados
207
+ """
208
+ # URL base + endpoint
209
+ full_url = f"{base_url.rstrip('/')}{endpoint}"
210
+
211
+ # Adicionar image_url padrão
212
+ url_params = {"image_url": "https://placehold.co/1080x1350.png"}
213
+
214
+ # Adicionar outros parâmetros
215
+ for key, value in params.items():
216
+ if value is not None:
217
+ url_params[key] = str(value)
218
+
219
+ # Construir query string
220
+ query_string = urlencode(url_params, quote_via=quote)
221
+ return f"{full_url}?{query_string}"
222
+
223
+ def generate_urls_from_result(result: dict, base_url: str = "https://habulaj-newapi-clone2.hf.space") -> list:
224
+ """
225
+ Gera as URLs formatadas a partir do resultado JSON
226
+ """
227
+ urls = []
228
+
229
+ # Se for notícia simples
230
+ if "endpoint" in result and "params" in result:
231
+ url = format_url(base_url, result["endpoint"], result["params"])
232
+ # Corrigir citation na URL se presente
233
+ url = fix_url_citation(url)
234
+ urls.append(url)
235
+
236
+ # Se for carrossel com capa e slides
237
+ elif "cover" in result or "slides" in result:
238
+ # Adicionar URL da capa
239
+ if "cover" in result:
240
+ cover_url = format_url(
241
+ base_url,
242
+ result["cover"]["endpoint"],
243
+ result["cover"]["params"]
244
+ )
245
+ # Corrigir citation na URL se presente
246
+ cover_url = fix_url_citation(cover_url)
247
+ urls.append(cover_url)
248
+
249
+ # Adicionar URLs dos slides
250
+ if "slides" in result:
251
+ for slide in result["slides"]:
252
+ slide_url = format_url(
253
+ base_url,
254
+ slide["endpoint"],
255
+ slide["params"]
256
+ )
257
+ # Corrigir citation na URL se presente
258
+ slide_url = fix_url_citation(slide_url)
259
+ urls.append(slide_url)
260
+
261
+ return urls
262
+
263
+ @router.post("/generate-poster", response_model=PosterResponse)
264
+ async def generate_poster(request: PosterRequest):
265
+ """
266
+ Endpoint para gerar posters automáticos (notícias ou carrosséis) usando o modelo Gemini.
267
+ """
268
+ try:
269
+ # Verificar API key
270
+ api_key = os.environ.get("GEMINI_API_KEY")
271
+ if not api_key:
272
+ raise HTTPException(status_code=500, detail="API key não configurada")
273
+
274
+ client = genai.Client(api_key=api_key)
275
+ model = "gemini-2.5-flash"
276
+
277
+ # System instructions
278
+ SYSTEM_INSTRUCTIONS = """
279
+ Você é uma IA especializada em gerar posters automáticos (notícias ou carrosséis de slides) a partir de texto noticioso fornecido.
280
+
281
+ 1. Analise o texto recebido.
282
+ 2. Se for uma notícia simples e direta → gere um único JSON correspondente ao endpoint /cover/news.
283
+ 3. Se o texto for longo, didático, explicativo, ou parecer adequado para um carrossel de slides → gere um único JSON contendo:
284
+ - O objeto da capa (/create/cover/image)
285
+ - Uma lista slides com cada slide (/create/image).
286
+
287
+ 📌 Estrutura esperada do JSON
288
+
289
+ 1. Notícia simples → /cover/news
290
+
291
+ {
292
+ "endpoint": "/cover/news",
293
+ "params": {
294
+ "headline": "Título da notícia seguindo padrão brasileiro",
295
+ "text_position": "bottom"
296
+ },
297
+ "instagram_description": "Descrição aqui"
298
+ }
299
+
300
+ 2. Carrossel de slides → capa + slides
301
+
302
+ {
303
+ "cover": {
304
+ "endpoint": "/create/cover/image",
305
+ "params": {
306
+ "title": "Título principal seguindo padrão brasileiro",
307
+ "title_position": "bottom"
308
+ }
309
+ },
310
+ "slides": [
311
+ {
312
+ "endpoint": "/create/image",
313
+ "params": {
314
+ "text": "Texto do primeiro slide (aceita <strong> e <em>)",
315
+ "text_position": "bottom",
316
+ "citation": null
317
+ }
318
+ },
319
+ {
320
+ "endpoint": "/create/image",
321
+ "params": {
322
+ "text": "Texto do segundo slide",
323
+ "text_position": "bottom",
324
+ "citation": "Citação curta e direta (opcional)",
325
+ "citation_direction": "text-top"
326
+ }
327
+ }
328
+ ],
329
+ "instagram_description": "Descrição aqui"
330
+ }
331
+
332
+ 🎯 Regras importantes para títulos e textos:
333
+ - TÍTULOS (headline, title): Siga o padrão brasileiro de capitalização:
334
+ * Primeira letra maiúscula
335
+ * Demais palavras em minúsculo
336
+ * EXCETO: nomes próprios, títulos de filmes, séries, livros, bandas, etc.
337
+ * Exemplo: "Lady Gaga anuncia novo álbum 'Chromatica Ball'"
338
+ * Exemplo: "Netflix cancela série 'The OA' após duas temporadas"
339
+
340
+ - CITAÇÕES (citation):
341
+ * Devem ser CURTAS e DIRETAS (máximo 60 caracteres)
342
+ * Use apenas para frases impactantes, declarações ou destaques
343
+ * NÃO use tags HTML em citações
344
+ * Exemplo bom: "Foi uma experiência transformadora"
345
+ * Exemplo ruim: "Esta foi realmente uma experiência muito transformadora que mudou completamente a minha vida"
346
+
347
+ - FORMATAÇÃO DE TEXTO:
348
+ * Use apenas <strong> para negrito e <em> para itálico
349
+ * NÃO use outras tags como <wiki>, <span>, etc.
350
+ * Se precisar enfatizar algo, escolha entre <strong> OU <em>, não ambos juntos
351
+
352
+ Nunca utilize as palavras "icônico" ou "icônicos" ao se referir a pessoas, acontecimentos ou objetos neste contexto. O jornal em questão é um veículo de direita, com um público majoritariamente conservador, e esse termo pode soar inadequado ou destoar do tom editorial esperado.
353
+ Em vez disso, prefira sempre sinônimos como "lendário", "lendários", "memorável", "histórico" ou outros termos que transmitam grandeza e relevância, mas mantendo a coerência com a linha editorial conservadora.
354
+
355
+ - DESCRIÇÃO PRO INSTAGRAM:
356
+
357
+ Resumo de 2-3 parágrafos com os principais pontos da notícia, mas sem revelar tudo. Termine SEMPRE com uma chamada como "🔗 Leia mais sobre [tema] no link da nossa bio." ou variação similar. Nunca utilize exclamações no link da bio. Adicione no máximo 5 hashtags no final.
358
+
359
+ Não use palavras genéricas ou pontuações genéricas na geração da descrição pro instagram. Evite exclamações, emojis e sempre respeite os espaços. Nas hashtags, sempre inclua hashtags diretas e populares.
360
+
361
+ Valores possíveis:
362
+
363
+ /cover/news
364
+ text_position = top | bottom
365
+
366
+ /create/image
367
+ text_position = top | bottom
368
+ citation_direction = top | bottom | text-top | text-bottom
369
+
370
+ /create/cover/image
371
+ title_position = top | bottom
372
+
373
+ IMPORTANTE: Retorne apenas o JSON válido, sem explicações adicionais. Além disso, para caso de slides, deve ser 9 slides no máximo. 10 contando com a capa.
374
+ """
375
+
376
+ # Exemplo 1 - Input do usuário
377
+ exemplo_1_input = """Saltando para um pequeno palco no sul da Califórnia, poderia ser apenas o elenco de qualquer peça escolar nos Estados Unidos.
378
+
379
+ A jornada deles até a noite de estreia, no entanto, foi marcada por incêndio e perda.
380
+ O incêndio Eaton destruiu sua escola. Eles criaram um novo País das Maravilhas no palco.
381
+
382
+ Todos os sábados desta primavera, dezenas de crianças se reuniam no ginásio de uma escola em Pasadena, Califórnia. Sentavam-se de pernas cruzadas, segurando seus roteiros, enquanto as falas de "Alice no País das Maravilhas" ecoavam pelas paredes.
383
+
384
+ Nos limites dos ensaios semanais, a vida parecia quase normal. Mas fora dali, elas lidavam com perdas em escala devastadora.
385
+
386
+ Em janeiro, o incêndio Eaton destruiu a escola primária deles — uma série de salas cercadas por jardins e pomares, nos pés das montanhas San Gabriel, em Altadena.
387
+
388
+ O fogo consumiu as casas de pelo menos sete integrantes do elenco e deixou outras inabitáveis. Dezenas de colegas partiram para outras escolas, estados e até países.
389
+
390
+ O incêndio também destruiu o palco, forçando os ensaios a acontecerem em uma quadra de basquete, com iluminação fluorescente e acústica estranha.
391
+
392
+ Passamos cinco meses acompanhando esse grupo de cerca de 40 alunos, enquanto se preparavam para a grande apresentação.
393
+
394
+ Para muitos, os ensaios semanais lembravam os de antes do incêndio. Eles pintavam cenários, lanchavam bananas e chips de churrasco.
395
+
396
+ E mergulhavam na história da estranha jornada de Alice por um buraco profundo e sombrio, rumo a um lugar onde nada fazia sentido.
397
+
398
+ Pergunte a qualquer pai ou professor: a Aveson School of Leaders tinha o campus mais bonito do condado de Los Angeles. Era uma rede de prédios de estuque colorido, com jardins e pátios. Algumas aulas eram dadas em uma tenda. Os alunos criavam galinhas no jardim.
399
+
400
+ "Era um pedaço de paraíso", disse Daniela Anino, diretora do colégio.
401
+
402
+ O incêndio Eaton transformou tudo em ruínas carbonizadas. As galinhas também morreram.
403
+
404
+ Para Cecily Dougall, os dias após o fogo foram um borrão. Sua casa sobreviveu, mas quase todo o resto se perdeu.
405
+
406
+ "Foi a primeira experiência assustadora que tive", disse Cecily, de 10 anos. "Nem sei por que essas coisas acontecem."
407
+
408
+ No início, parecia impensável que o musical da primavera fosse acontecer. Mas a direção decidiu rapidamente que deveria continuar.
409
+
410
+ "Todos acreditamos que as artes são cruciais para a vida, especialmente para processar algo tão traumático", disse Jackie Gonzalez-Durruthy, da ONG Arts Bridging the Gap, que ajuda a manter o programa de teatro da escola.
411
+
412
+ Quando chegaram as audições, em fevereiro, Cecily (que usa pronomes neutros) não quis cantar. Sentia que sua voz estava trêmula, refletindo medo e tristeza.
413
+
414
+ **Um farol de normalidade**
415
+
416
+ Os ensaios mudaram-se para o ginásio do campus de ensino médio da Aveson, em Pasadena. Ali, os atores marcavam cenas sobre as linhas da quadra de basquete, e as tabelas serviam de coxias improvisadas.
417
+
418
+ A rotina dos ensaios de sábado virou um fio de esperança para muitas famílias — um lembrete de como as coisas eram antes.
419
+
420
+ "É praticamente igual a quando fizemos Matilda e Shrek", disse Lila Avila-Brewster, de 10 anos, cuja família perdeu a casa no incêndio. "Parece bem parecido."
421
+
422
+ Para a mãe de Lila, Paloma Avila (que usa pronomes neutros), os encontros eram também uma rede de apoio.
423
+
424
+ "Era tipo: 'Quem precisa de sapatos? Quem precisa de escovas de dente?'", contou.
425
+
426
+ Lila queria ser o Gato de Cheshire, mas acabou escalada como Petúnia — uma das flores que zombam de Alice. Depois percebeu que gostava mais desse papel. "As flores são metidas e acham que são melhores que todo mundo", disse.
427
+
428
+ Já no fim de março, a tristeza que marcou a audição de Cecily já não era tão sufocante. Elu abraçou o papel do Chapeleiro Maluco, memorizando falas e músicas com tanta precisão que Gonzalez-Durruthy chamou elu de "pequeno metrônomo".
429
+
430
+ Annika, irmã mais velha de Cecily, ouviu pais comentando sobre o quanto as crianças tinham sofrido. Mas discordou.
431
+
432
+ "Isso é só com o que estamos lidando", disse.
433
+
434
+ Para Eden Javier, de 11 anos, os ensaios eram divertidos, mas ela sentia falta do palco. "É como se você tivesse poder quando está lá em cima", disse. No chão do ginásio, era mais difícil imaginar o País das Maravilhas.
435
+
436
+ A perda do palco parecia pequena diante de tantas outras, mas ainda assim doía. O trabalho escolar de Eden sobre cegueira queimou junto com a escola. As novas salas de aula eram estranhas. Amigos deixaram a Aveson.
437
+
438
+ Em aula, ela escreveu uma ode a algo que o fogo havia levado:
439
+
440
+ "O palco, o palco, / meu lugar de conforto, / o palco, o palco, / meu lugar de confiança. / O palco, o palco. / Já não está aqui."
441
+
442
+ Mike Marks, diretor e professor de teatro da Aveson, também foi deslocado pelos incêndios, mas estava determinado a achar um palco. Ligou para todos os teatros, igrejas e escolas que conhecia. Duas semanas depois, a vizinha Barnhart School ofereceu o auditório.
443
+
444
+ Quando Marks entrou e viu os alunos rindo e correndo em círculos, sentiu como se o tempo tivesse voltado.
445
+
446
+ "Se eu não soubesse que uma catástrofe enorme tinha acontecido aqui", disse, "nem teria percebido diferença."""
447
+
448
+ # Exemplo 1 - Output esperado
449
+ exemplo_1_output = """{
450
+ "cover": {
451
+ "endpoint": "/create/cover/image",
452
+ "params": {
453
+ "title": "O incêndio em Eaton destruiu a escola deles. Eles criaram um novo mundo encantado no palco.",
454
+ "title_position": "top"
455
+ }
456
+ },
457
+ "slides": [
458
+ {
459
+ "endpoint": "/create/image",
460
+ "params": {
461
+ "text": "Em janeiro, o incêndio de Eaton devastou a escola primária Aveson School of Leaders. Parecia impensável que o musical da primavera acontecesse, mas a direção da escola rapidamente decidiu que ele deveria continuar.",
462
+ "text_position": "bottom",
463
+ "citation": null
464
+ }
465
+ },
466
+ {
467
+ "endpoint": "/create/image",
468
+ "params": {
469
+ "text": "E assim, dezenas de crianças começaram a se reunir no ginásio da escola secundária e do ensino médio da Aveson todos os sábados. Sentavam-se de pernas cruzadas, segurando seus roteiros, enquanto as falas de Alice no País das Maravilhas ecoavam pelas paredes.",
470
+ "text_position": "top",
471
+ "citation": null
472
+ }
473
+ },
474
+ {
475
+ "endpoint": "/create/image",
476
+ "params": {
477
+ "text": "O incêndio havia consumido as casas de pelo menos sete membros do elenco e tornado outras inabitáveis. Dezenas de colegas deixaram a cidade ou se mudaram, como a <strong>Ruby Hull</strong> — escalada para viver a Pequena Alice — cuja família se mudou seis horas ao norte.",
478
+ "text_position": "bottom",
479
+ "citation": null
480
+ }
481
+ },
482
+ {
483
+ "endpoint": "/create/image",
484
+ "params": {
485
+ "text": "Para <strong>Paloma Ávila</strong> — mãe de <strong>Lila</strong>, escalada para viver Petúnia — a rotina dos ensaios de sábado se tornou um ponto de apoio para reencontrar outros pais depois de perder a casa, e também uma lembrança de como as coisas costumavam ser.",
486
+ "text_position": "bottom",
487
+ "citation": "Era assim: 'Ok, quem precisa de sapatos? Quem precisa de escovas de dente?'",
488
+ "citation_direction": "top"
489
+ }
490
+ }
491
+ ],
492
+ "instagram_description": "Para as crianças da Aveson School of Leaders em Altadena, Califórnia, a vida parecia quase normal durante os ensaios do espetáculo. Mas fora da escola, elas enfrentavam perdas em uma escala impressionante. Em janeiro, o incêndio em Eaton destruiu a escola primária e as casas de muitos estudantes. Eles planejavam apresentar “Alice no País das Maravilhas” e, apesar do caminho de recuperação, os líderes da escola acharam que valia a pena descobrir como garantir que o espetáculo acontecesse.\n\n🔗 Confira a jornada completa no link que está na nossa bio.\n\n#AliceNoPaísDasMaravilhas #IncêndioEaton #Resiliência #Teatro #California"
493
+ }"""
494
+ exemplo_2_input = """
495
+ Antes de conquistar sua primeira indicação ao Emmy por “Severance”, Zach Cherry passava seus dias em um escritório em Manhattan. O ator trabalhou durante anos como gerente em uma organização sem fins lucrativos, função que lhe permitia conciliar a rotina administrativa com sua verdadeira paixão: a comédia de improviso.
496
+
497
+ Cherry, hoje com 37 anos, começou a se dedicar ao improviso ainda na adolescência, em acampamentos e na escola, continuando na faculdade em Amherst. Depois da graduação, participou ativamente do circuito nova-iorquino, especialmente no Upright Citizens Brigade Theater, enquanto buscava papéis em produções de TV e cinema.
498
+
499
+ Aos poucos, foi conquistando espaço em séries como Crashing, produzida por Judd Apatow, onde interpretou um gerente atrapalhado, e em You, thriller exibido pela Lifetime e Netflix. Foi nesse momento que percebeu que poderia finalmente viver da atuação. “Achei que o valor pago por episódio seria pelo trabalho inteiro da temporada e mesmo assim fiquei animado. Percebi que poderia fazer disso minha profissão”, recorda.
500
+
501
+ No cinema, Cherry também participou de filmes como Homem-Aranha: De Volta ao Lar, mas foi em Severance, da Apple TV+, que alcançou maior destaque. Na série, ele interpreta Dylan G., um funcionário da misteriosa Lumon Industries, papel que lhe rendeu uma indicação ao Emmy de melhor ator coadjuvante em drama. A produção soma 27 indicações e colocou Cherry ao lado de nomes como Adam Scott, Christopher Walken, John Turturro e Tramell Tillman.
502
+
503
+ Apesar da confiança, o ator admite sentir a pressão no set, principalmente fora do gênero cômico. “Na comédia, eu sei quando estou indo bem ou não. Mas em algo como Severance é um salto maior de fé”, disse. Na segunda temporada, lançada em janeiro, seu personagem vive desde momentos íntimos, como cenas com Merritt Wever, até aventuras físicas em locações como o Minnewaska State Park, em Nova York.
504
+
505
+ De um escritório real para o fictício e perturbador ambiente de Severance, Zach Cherry mostra que a disciplina do passado e a paixão pelo improviso foram essenciais para chegar ao momento mais marcante de sua carreira.
506
+ """
507
+
508
+ exemplo_2_output = """
509
+ {
510
+ "endpoint": "/cover/news",
511
+ "params": {
512
+ "headline": "'Ruptura' foi um salto de fé para Zach Cherry",
513
+ "text_position": "bottom"
514
+ },
515
+ "instagram_description": "Antes de conquistar sua primeira indicação ao Emmy por 'Severance', Zach Cherry conciliava seu trabalho em Manhattan com a paixão pelo improviso. Atuando em séries como 'Crashing' e 'You', e no cinema em 'Homem-Aranha: De Volta ao Lar', ele encontrou reconhecimento ao interpretar Dylan G., funcionário da Lumon Industries, em 'Severance', papel que lhe rendeu a primeira indicação ao Emmy.\n\n🔗 Leia mais sobre a trajetória de Zach Cherry no link da nossa bio.\n\n#Severance #ZachCherry #AppleTV #Emmy #Ator #Carreira #TVeCinema"
516
+ }
517
+ """
518
+ exemplo_3_input = """
519
+ 8 Mulheres, 4 Quartos e 1 Causa: Quebrando o Teto de Vidro da IA\n\nFoundHer House, uma casa em Glen Park, São Francisco, é uma rara residência de hackers totalmente feminina, onde as moradoras criam uma comunidade de apoio para desenvolver suas startups.\n\nEm uma tarde recente, Miki Safronov-Yamamoto, 18 anos, e algumas colegas sentaram-se em cadeiras diferentes ao redor da mesa de jantar de sua casa de dois andares. Entre enviar e-mails e checar mensagens no LinkedIn, discutiam como organizar um “demo day”, onde mostrariam suas startups para investidores.\n\nMiki, a mais jovem da casa e caloura na University of Southern California, sugeriu que discutissem discretamente a duração das apresentações — talvez três minutos. Ava Poole, 20 anos, que desenvolve um agente de IA para facilitar pagamentos digitais, perguntou se a plateia seria principalmente de investidores. Miki respondeu que haveria investidores e fundadoras de startups. Chloe Hughes, 21 anos, criando uma plataforma de IA para imóveis comerciais, ouvia música de fundo.\n\nFoundHer House foi criada em maio como uma “hacker house” voltada especificamente para mulheres. O objetivo era criar uma comunidade de apoio para suas oito residentes construírem suas próprias empresas em São Francisco, capital tecnológica dos EUA.\n\nO boom da IA tem sido dominado por homens, e dados mostram que poucas empresas de IA têm fundadoras mulheres. Navrina Singh, CEO da Credo AI, disse que há uma disparidade clara e que as mulheres líderes na área não são bem financiadas. Dos 3.212 acordos de venture capital com startups de IA até meados de agosto de 2025, menos de 20% envolveram empresas com pelo menos uma fundadora mulher.\n\nFoundHer House tentou contrariar essa tendência. Fundada por Miki e Anantika Mannby, 21 anos, estudante da University of Southern California, que desenvolve uma startup de compras digitais, a casa adicionou outras seis residentes, incluindo Ava Poole e Chloe Hughes. As outras são Sonya Jin, 20 anos, criando uma startup para treinar agentes de IA; Danica Sun, 19 anos, trabalhando em energia limpa; Fatimah Hussain, 19 anos, criando um programa de mentoria online; e Naciima Mohamed, 20 anos, desenvolvendo uma ferramenta de IA para ajudar crianças a entender diagnósticos médicos.\n\nApesar dos grandes sonhos, a casa fechará na terça-feira seguinte. Miki, Anantika e quatro outras residentes voltarão para a faculdade; Sonya e Naciima abandonaram os estudos para continuar suas startups. Das oito startups, duas receberam investimento e seis lançaram produtos.\n\nMiki e Anantika criaram FoundHer House ao se mudarem para São Francisco durante o verão. Encontraram um Airbnb acessível em Glen Park, com quatro quartos e três banheiros, alugado por cerca de 40.000 dólares para o verão, com ajuda financeira de investidores. Cada residente paga entre 1.100 e 1.300 dólares de aluguel por mês.\n\nO local tornou-se um ponto de encontro para jantares e discussões de painel patrocinados por firmas de venture capital como Andreessen Horowitz, Bain Capital Ventures e Kleiner Perkins. Organizaram um demo day em 19 de agosto para apresentar suas startups a investidores, com apresentações de quatro minutos para cada residente.\n\nAileen Lee, fundadora da Cowboy Ventures, comentou que foi um dos melhores demo days que já participou, destacando que ainda há muito a melhorar quanto à presença feminina na IA.
520
+ """
521
+
522
+ exemplo_3_output = """
523
+ {
524
+ "cover": {
525
+ "endpoint": "/create/cover/image",
526
+ "params": {
527
+ "title": "A casa de hackers só de mulheres que tenta quebrar o teto de vidro da I.A",
528
+ "title_position": "top"
529
+ }
530
+ },
531
+ "slides": [
532
+ {
533
+ "endpoint": "/create/image",
534
+ "params": {
535
+ "text": "A FoundHer House, uma residência no bairro Glen Park em San Francisco, é uma rara casa de hackers só para mulheres, onde as moradoras estão criando uma comunidade de apoio para desenvolver suas startups.",
536
+ "text_position": "bottom",
537
+ "citation": null
538
+ }
539
+ },
540
+ {
541
+ "endpoint": "/create/image",
542
+ "params": {
543
+ "text": "<strong>Ke Naciima Mohamed</strong>, à direita, está desenvolvendo uma ferramenta de I.A. para ajudar crianças a entenderem seus diagnósticos médicos.",
544
+ "text_position": "bottom",
545
+ "citation": "“Eu não queria vir para San Francisco e me isolar enquanto estou construindo.”",
546
+ "citation_direction": "text-top"
547
+ }
548
+ }
549
+ ],
550
+ "instagram_description": "A FoundHer House, uma “hacker house”, é um ambiente de co-living raro em San Francisco voltado especificamente para mulheres. As residentes têm uma comunidade de apoio enquanto constroem suas próprias empresas de tecnologia e economizam com despesas.\n\nÀ medida que o Vale do Silício se agita com jovens que querem trabalhar com inteligência artificial, start-ups emergentes e hacker houses têm sido dominadas por homens, de acordo com investidores e dados de financiamento.\n\n🔗 No link da nossa bio, leia mais sobre as oito residentes e como o boom da I.A. deve perpetuar a demografia da indústria de tecnologia.\n\n#FoundHerHouse #HackerHouse #MulheresNaTecnologia #IA #Startups"
551
+ }
552
+ """
553
+
554
+ exemplo_4_input = """
555
+ Atenção: este artigo contém spoilers importantes sobre o enredo e o final do filme \"Weapons\".\n\nLançado em 8 de agosto, \"Weapons\", o novo filme de Zach Cregger (diretor de \"Bárbaro\"), rapidamente se tornou um sucesso de crítica e bilheteria, arrecadando mais de 199 milhões de dólares em todo o mundo. O longa parte de uma premissa assustadora: em uma noite, às 2:17 da manhã, dezessete crianças da mesma turma escolar acordam, saem de suas casas e desaparecem na escuridão, sem deixar rastros.\n\nA história se desenrola de forma não linear, apresentando os eventos a partir da perspectiva de vários personagens, montando gradualmente o quebra-cabeça para o espectador.\n\nQuem é a vilã e o que ela queria?\n\nA responsável pelo desaparecimento é Gladys (Amy Madigan), tia de Alex (Cary Christopher), o único aluno que não sumiu. Gladys é uma bruxa que precisa drenar a energia vital de outras pessoas para rejuvenescer e sobreviver. Antes do sequestro em massa, ela já havia enfeitiçado os pais de Alex, que permanecem em estado catatônico dentro de casa, servindo como sua primeira fonte de energia.\n\nPara manipular Alex e garantir seu silêncio, Gladys força os pais do garoto a se esfaquearem com garfos. Amedrontado, Alex é coagido a roubar os crachás com os nomes de seus colegas de classe. Usando esses itens pessoais, Gladys lança um feitiço que faz as dezessete crianças correrem para a casa de Alex, onde são mantidas em transe no porão, servindo como \"bateria\" de força vital.\n\nComo o plano é descoberto?\n\nA trama se concentra em três personagens principais: a professora Justine Gandy (Julia Garner), que se torna a principal suspeita da cidade; Archer Graff (Josh Brolin), pai de um dos meninos desaparecidos; e Paul (Alden Ehrenreich), policial e ex-namorado de Justine.\n\nA investigação avança quando Gladys decide eliminar Justine. Usando um feitiço, ela transforma o diretor da escola, Marcus (Benedict Wong), em uma \"arma\" irracional, enviando-o para atacar a professora. Archer testemunha o ataque e, após Marcus ser atropelado e morto, percebe que algo sobrenatural está acontecendo. Ele e Justine se unem e, ao triangular a rota de fuga das crianças, descobrem que todas as direções apontam para a casa de Alex.\n\nO que acontece no final?\n\nAo chegarem à casa, Justine e Archer são atacados por outras pessoas controladas por Gladys, incluindo o policial Paul. Os pais de Alex, também enfeitiçados, tentam matar o próprio filho. Em um ato de desespero, Alex cria um novo encantamento que \"arma\" seus dezessete colegas de classe, direcionando a fúria deles contra Gladys.\n\nA bruxa se torna vítima. Perseguida pelas crianças, Gladys é brutalmente despedaçada. Com sua morte, todos os feitiços são quebrados. Archer, os pais de Alex e os demais enfeitiçados voltam ao normal. Os pais de Alex são internados devido ao trauma, e o garoto passa a viver com outra tia. As crianças são devolvidas às famílias, mas muitas permanecem traumatizadas e sem falar.\n\nSímbolos e perguntas não respondidas\n\nO filme deixa algumas imagens e perguntas em aberto, provocando debates. O horário do desaparecimento, 2:17, é referência ao quarto 217 do livro \"O Iluminado\", de Stephen King. Em um sonho de Archer, um rifle de assalto flutua sobre a casa de Alex, levando a interpretações sobre o título ser uma alegoria a tiroteios em escolas. No entanto, Zach Cregger afirmou que prefere deixar o significado da cena aberto à interpretação do público, em vez de fixá-lo a uma declaração política.
556
+ """
557
+
558
+ exemplo_4_output = """
559
+ {
560
+ "endpoint": "/cover/news",
561
+ "params": {
562
+ "headline": "Final explicado de \"Weapons\": o que aconteceu com as crianças?",
563
+ "text_position": "bottom"
564
+ },
565
+ "instagram_description": "Spoilers abaixo!\n\nO filme Weapons, de Zach Cregger, acompanha o desaparecimento de dezessete crianças durante a madrugada, todas manipuladas pela bruxa Gladys (Amy Madigan), tia de Alex (Cary Christopher). Ela drena a energia vital dos alunos para se manter jovem, mantendo-os em transe no porão da casa de Alex, enquanto os pais do garoto também são enfeitiçados.\n\nNo final, Alex cria um feitiço que usa a energia contra Gladys. A bruxa é derrotada após as crianças atacá-la, e todos os feitiços são quebrados. Após seus pais serem internados por causa do trauma, Alex vai morar com outra tia. Muitas crianças permanecem traumatizadas e em silêncio, apesar de todos serem salvos.\n\nO filme contém alusões enigmáticas, como o horário 2:17, que está ligado ao Quarto 217 em O Iluminado, e imagens ambíguas que remetem a discussões sobre violência escolar e ao título. Zach Cregger prefere deixar que o público tire suas próprias conclusões a partir dessas pistas.\n\n🔗 Toda a história e detalhes do final tão no link da bio.\n\n#Weapons #FinalExplicado #ZachCregger #Suspense #Cinema"
566
+ }
567
+ """
568
+
569
+ # Configuração da geração
570
+ config = types.GenerateContentConfig(
571
+ system_instruction=SYSTEM_INSTRUCTIONS,
572
+ response_mime_type="application/json",
573
+ max_output_tokens=8000,
574
+ temperature=0.7,
575
+ )
576
+
577
+ # Conteúdo da conversa com exemplos few-shot
578
+ contents = [
579
+ # Exemplo 1 - User
580
+ types.Content(
581
+ role="user",
582
+ parts=[types.Part.from_text(text=exemplo_1_input)]
583
+ ),
584
+ # Exemplo 1 - Assistant (Model)
585
+ types.Content(
586
+ role="model",
587
+ parts=[types.Part.from_text(text=exemplo_1_output)]
588
+ ),
589
+ # Exemplo 2 - User
590
+ types.Content(
591
+ role="user",
592
+ parts=[types.Part.from_text(text=exemplo_2_input)]
593
+ ),
594
+ # Exemplo 2 - Assistant (Model)
595
+ types.Content(
596
+ role="model",
597
+ parts=[types.Part.from_text(text=exemplo_2_output)]
598
+ ),
599
+ # Exemplo 3 - User
600
+ types.Content(
601
+ role="user",
602
+ parts=[types.Part.from_text(text=exemplo_3_input)]
603
+ ),
604
+ # Exemplo 3 - Assistant (Model)
605
+ types.Content(
606
+ role="model",
607
+ parts=[types.Part.from_text(text=exemplo_3_output)]
608
+ ),
609
+ # Exemplo 4 - User
610
+ types.Content(
611
+ role="user",
612
+ parts=[types.Part.from_text(text=exemplo_4_input)]
613
+ ),
614
+ # Exemplo 4 - Assistant (Model)
615
+ types.Content(
616
+ role="model",
617
+ parts=[types.Part.from_text(text=exemplo_4_output)]
618
+ ),
619
+ # Input real do usuário
620
+ types.Content(
621
+ role="user",
622
+ parts=[types.Part.from_text(text=request.content)]
623
+ )
624
+ ]
625
+
626
+ # Gerar conteúdo
627
+ response = client.models.generate_content(
628
+ model=model,
629
+ contents=contents,
630
+ config=config
631
+ )
632
+
633
+ logger.info("Resposta do modelo recebida com sucesso")
634
+
635
+ # Extrair texto da resposta
636
+ response_text = ""
637
+ if hasattr(response, 'text') and response.text:
638
+ response_text = response.text
639
+ elif hasattr(response, 'candidates') and response.candidates:
640
+ for candidate in response.candidates:
641
+ if hasattr(candidate, 'content') and candidate.content:
642
+ if hasattr(candidate.content, 'parts') and candidate.content.parts:
643
+ for part in candidate.content.parts:
644
+ if hasattr(part, 'text') and part.text:
645
+ response_text += part.text
646
+
647
+ if not response_text or response_text.strip() == "":
648
+ logger.error("Resposta do modelo está vazia")
649
+ raise HTTPException(
650
+ status_code=500,
651
+ detail="Modelo não retornou conteúdo válido"
652
+ )
653
+
654
+ # Limpar caracteres de controle antes do parse
655
+ clean_response = clean_json_string(response_text)
656
+
657
+ # Parse do JSON
658
+ try:
659
+ result_json = json.loads(clean_response)
660
+ except json.JSONDecodeError as e:
661
+ logger.error(f"Erro ao fazer parse do JSON: {e}")
662
+ logger.error(f"Resposta original: {response_text}")
663
+ logger.error(f"Resposta limpa: {clean_response}")
664
+
665
+ # Tentar uma limpeza mais agressiva como fallback
666
+ try:
667
+ # Remove quebras de linha e espaços extras
668
+ fallback_clean = re.sub(r'\s+', ' ', response_text.strip())
669
+ # Remove caracteres de controle
670
+ fallback_clean = ''.join(char for char in fallback_clean if ord(char) >= 32 or char in [' ', '\t'])
671
+ result_json = json.loads(fallback_clean)
672
+ logger.info("Parse bem-sucedido com limpeza de fallback")
673
+ except json.JSONDecodeError as fallback_error:
674
+ logger.error(f"Erro no fallback também: {fallback_error}")
675
+ raise HTTPException(
676
+ status_code=500,
677
+ detail=f"Resposta do modelo não é um JSON válido: {str(e)}"
678
+ )
679
+
680
+ # Gerar URLs formatadas
681
+ formatted_urls = generate_urls_from_result(result_json)
682
+
683
+ logger.info("Processamento concluído com sucesso")
684
+ logger.info(f"URLs geradas: {formatted_urls}")
685
+
686
+ return PosterResponse(result=result_json, urls=formatted_urls)
687
+
688
+ except HTTPException:
689
+ raise
690
+ except Exception as e:
691
+ logger.error(f"Erro na geração do poster: {str(e)}")
692
+ raise HTTPException(status_code=500, detail=str(e))
routers/memoriam.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, List, Dict
7
+ import logging
8
+ from urllib.parse import quote
9
+ from datetime import datetime
10
+
11
+ # Configurar logging
12
+ logging.basicConfig(level=logging.INFO)
13
+ log = logging.getLogger("memoriam-api")
14
+
15
+ router = APIRouter()
16
+
17
+ def download_image_from_url(url: str) -> Image.Image:
18
+ headers = {
19
+ "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"
20
+ }
21
+ response = requests.get(url, headers=headers)
22
+ if response.status_code != 200:
23
+ raise HTTPException(status_code=400, detail=f"Imagem não pôde ser baixada. Código {response.status_code}")
24
+ try:
25
+ return Image.open(BytesIO(response.content)).convert("RGB")
26
+ except Exception as e:
27
+ raise HTTPException(status_code=400, detail=f"Erro ao abrir imagem: {str(e)}")
28
+
29
+ def resize_and_crop_to_fill(img: Image.Image, target_width: int, target_height: int) -> Image.Image:
30
+ img_ratio = img.width / img.height
31
+ target_ratio = target_width / target_height
32
+
33
+ if img_ratio > target_ratio:
34
+ scale_height = target_height
35
+ scale_width = int(scale_height * img_ratio)
36
+ else:
37
+ scale_width = target_width
38
+ scale_height = int(scale_width / img_ratio)
39
+
40
+ img_resized = img.resize((scale_width, scale_height), Image.LANCZOS)
41
+
42
+ left = (scale_width - target_width) // 2
43
+ top = (scale_height - target_height) // 2
44
+ right = left + target_width
45
+ bottom = top + target_height
46
+
47
+ return img_resized.crop((left, top, right, bottom))
48
+
49
+ def create_bottom_black_gradient(width: int, height: int) -> Image.Image:
50
+ """Cria um gradiente preto suave que vai do topo transparente até a metade da imagem preto"""
51
+ gradient = Image.new("RGBA", (width, height), color=(0, 0, 0, 0))
52
+ draw = ImageDraw.Draw(gradient)
53
+
54
+ for y in range(height):
55
+ # Gradiente mais suave que começa transparente e vai até metade da imagem
56
+ ratio = y / height
57
+ if ratio <= 0.6:
58
+ # Primeira parte: totalmente transparente
59
+ alpha = 0
60
+ elif ratio <= 0.75:
61
+ # Transição muito suave (60% a 75% da altura)
62
+ alpha = int(80 * (ratio - 0.6) / 0.15)
63
+ else:
64
+ # Final suave (75% a 100% da altura)
65
+ alpha = int(80 + 50 * (ratio - 0.75) / 0.25)
66
+
67
+ # Usar preto puro (0, 0, 0) com alpha mais baixo
68
+ draw.line([(0, y), (width, y)], fill=(0, 0, 0, alpha))
69
+
70
+ return gradient
71
+
72
+ def create_top_black_gradient(width: int, height: int) -> Image.Image:
73
+ """Cria um gradiente preto suave que vai do fundo transparente até a metade da imagem preto"""
74
+ gradient = Image.new("RGBA", (width, height), color=(0, 0, 0, 0))
75
+ draw = ImageDraw.Draw(gradient)
76
+
77
+ for y in range(height):
78
+ # Gradiente mais suave que começa preto e vai até metade da imagem
79
+ ratio = y / height
80
+ if ratio <= 0.25:
81
+ # Primeira parte suave (0% a 25% da altura)
82
+ alpha = int(80 + 50 * (0.25 - ratio) / 0.25)
83
+ elif ratio <= 0.4:
84
+ # Transição muito suave (25% a 40% da altura)
85
+ alpha = int(80 * (0.4 - ratio) / 0.15)
86
+ else:
87
+ # Segunda parte: totalmente transparente
88
+ alpha = 0
89
+
90
+ # Usar preto puro (0, 0, 0) com alpha mais baixo
91
+ draw.line([(0, y), (width, y)], fill=(0, 0, 0, alpha))
92
+
93
+ return gradient
94
+
95
+ def draw_text_left_aligned(draw: ImageDraw.Draw, text: str, x: int, y: int, font_path: str, font_size: int):
96
+ """Desenha texto alinhado à esquerda com especificações exatas"""
97
+ try:
98
+ font = ImageFont.truetype(font_path, font_size)
99
+ except Exception:
100
+ font = ImageFont.load_default()
101
+
102
+ # Espaçamento entre letras 0% e cor branca
103
+ draw.text((x, y), text, font=font, fill=(255, 255, 255), spacing=0)
104
+
105
+ def search_wikipedia(name: str) -> List[Dict]:
106
+ """
107
+ Busca nomes na Wikipedia e retorna lista com foto, nome completo e wikibase_item
108
+ """
109
+ try:
110
+ # Primeira busca para obter dados básicos e foto
111
+ search_url = "https://en.wikipedia.org/w/rest.php/v1/search/title"
112
+ search_params = {
113
+ "q": name,
114
+ "limit": 5 # Limite de resultados
115
+ }
116
+
117
+ headers = {
118
+ "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"
119
+ }
120
+
121
+ response = requests.get(search_url, params=search_params, headers=headers)
122
+ response.raise_for_status()
123
+ search_data = response.json()
124
+
125
+ results = []
126
+
127
+ for page in search_data.get("pages", []):
128
+ title = page.get("title", "")
129
+ description = page.get("description", "")
130
+ thumbnail = page.get("thumbnail", {})
131
+
132
+ # Obter wikibase_item usando a API de props
133
+ wikibase_item = None
134
+ try:
135
+ props_url = "https://en.wikipedia.org/w/api.php"
136
+ props_params = {
137
+ "action": "query",
138
+ "prop": "pageprops",
139
+ "titles": title,
140
+ "format": "json"
141
+ }
142
+
143
+ props_response = requests.get(props_url, params=props_params, headers=headers)
144
+ props_response.raise_for_status()
145
+ props_data = props_response.json()
146
+
147
+ pages = props_data.get("query", {}).get("pages", {})
148
+ for page_id, page_data in pages.items():
149
+ pageprops = page_data.get("pageprops", {})
150
+ wikibase_item = pageprops.get("wikibase_item")
151
+ break
152
+
153
+ except Exception as e:
154
+ log.warning(f"Erro ao obter wikibase_item para {title}: {e}")
155
+
156
+ # Construir URL completa da imagem
157
+ image_url = None
158
+ if thumbnail and thumbnail.get("url"):
159
+ thumb_url = thumbnail["url"]
160
+ # A URL vem como //upload.wikimedia... então precisa adicionar https:
161
+ if thumb_url.startswith("//"):
162
+ image_url = f"https:{thumb_url}"
163
+ # Converter para versão de tamanho maior (remover o /60px- e usar tamanho original)
164
+ image_url = image_url.replace("/60px-", "/400px-")
165
+ else:
166
+ image_url = thumb_url
167
+
168
+ result = {
169
+ "name": title,
170
+ "description": description,
171
+ "image_url": image_url,
172
+ "wikibase_item": wikibase_item
173
+ }
174
+
175
+ results.append(result)
176
+
177
+ return results
178
+
179
+ except Exception as e:
180
+ log.error(f"Erro na busca Wikipedia: {e}")
181
+ raise HTTPException(status_code=500, detail=f"Erro ao buscar na Wikipedia: {str(e)}")
182
+
183
+ def get_wikidata_dates(wikibase_item: str) -> Dict[str, Optional[str]]:
184
+ """
185
+ Consulta o Wikidata para obter datas de nascimento (P569) e falecimento (P570)
186
+ """
187
+ try:
188
+ if not wikibase_item or not wikibase_item.startswith('Q'):
189
+ return {"birth": None, "death": None}
190
+
191
+ url = f"https://www.wikidata.org/wiki/Special:EntityData/{wikibase_item}.json"
192
+ headers = {
193
+ "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"
194
+ }
195
+
196
+ response = requests.get(url, headers=headers)
197
+ response.raise_for_status()
198
+ data = response.json()
199
+
200
+ # Navegar até as claims
201
+ entities = data.get("entities", {})
202
+ entity_data = entities.get(wikibase_item, {})
203
+ claims = entity_data.get("claims", {})
204
+
205
+ birth_date = None
206
+ death_date = None
207
+
208
+ # Extrair data de nascimento (P569)
209
+ if "P569" in claims:
210
+ birth_claims = claims["P569"]
211
+ for claim in birth_claims:
212
+ try:
213
+ mainsnak = claim.get("mainsnak", {})
214
+ datavalue = mainsnak.get("datavalue", {})
215
+ value = datavalue.get("value", {})
216
+ time = value.get("time")
217
+ if time:
218
+ # Formato: +1943-08-17T00:00:00Z
219
+ year = time.split("-")[0].replace("+", "")
220
+ birth_date = year
221
+ break
222
+ except Exception as e:
223
+ log.warning(f"Erro ao processar data de nascimento: {e}")
224
+ continue
225
+
226
+ # Extrair data de falecimento (P570)
227
+ if "P570" in claims:
228
+ death_claims = claims["P570"]
229
+ for claim in death_claims:
230
+ try:
231
+ mainsnak = claim.get("mainsnak", {})
232
+ datavalue = mainsnak.get("datavalue", {})
233
+ value = datavalue.get("value", {})
234
+ time = value.get("time")
235
+ if time:
236
+ # Formato: +2023-01-15T00:00:00Z
237
+ year = time.split("-")[0].replace("+", "")
238
+ death_date = year
239
+ break
240
+ except Exception as e:
241
+ log.warning(f"Erro ao processar data de falecimento: {e}")
242
+ continue
243
+
244
+ return {"birth": birth_date, "death": death_date}
245
+
246
+ except Exception as e:
247
+ log.error(f"Erro ao consultar Wikidata para {wikibase_item}: {e}")
248
+ return {"birth": None, "death": None}
249
+
250
+ def create_canvas(image_url: Optional[str], name: Optional[str], birth: Optional[str], death: Optional[str], text_position: str = "bottom") -> BytesIO:
251
+ # Dimensões fixas para Instagram
252
+ width = 1080
253
+ height = 1350
254
+
255
+ canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0)) # Fundo transparente
256
+
257
+ # Carregar e processar imagem de fundo se fornecida
258
+ if image_url:
259
+ try:
260
+ img = download_image_from_url(image_url)
261
+ img_bw = ImageEnhance.Color(img).enhance(0.0).convert("RGBA")
262
+ filled_img = resize_and_crop_to_fill(img_bw, width, height)
263
+ canvas.paste(filled_img, (0, 0))
264
+ except Exception as e:
265
+ log.warning(f"Erro ao carregar imagem: {e}")
266
+
267
+ # Aplicar gradiente baseado na posição do texto
268
+ if text_position.lower() == "top":
269
+ gradient_overlay = create_top_black_gradient(width, height)
270
+ else: # bottom
271
+ gradient_overlay = create_bottom_black_gradient(width, height)
272
+
273
+ canvas = Image.alpha_composite(canvas, gradient_overlay)
274
+
275
+ # Adicionar logo no canto inferior direito com opacidade
276
+ try:
277
+ logo = Image.open("recurve.png").convert("RGBA")
278
+ logo_resized = logo.resize((120, 22))
279
+ # Aplicar opacidade à logo
280
+ logo_with_opacity = Image.new("RGBA", logo_resized.size)
281
+ logo_with_opacity.paste(logo_resized, (0, 0))
282
+ # Reduzir opacidade
283
+ logo_alpha = logo_with_opacity.split()[-1].point(lambda x: int(x * 0.42)) # 42% de opacidade
284
+ logo_with_opacity.putalpha(logo_alpha)
285
+
286
+ logo_padding = 40
287
+ logo_x = width - 120 - logo_padding
288
+ logo_y = height - 22 - logo_padding
289
+ canvas.paste(logo_with_opacity, (logo_x, logo_y), logo_with_opacity)
290
+ except Exception as e:
291
+ log.warning(f"Erro ao carregar a logo: {e}")
292
+
293
+ draw = ImageDraw.Draw(canvas)
294
+
295
+ # Configurar posições baseadas no text_position
296
+ text_x = 80 # Alinhamento à esquerda com margem
297
+
298
+ if text_position.lower() == "top":
299
+ dates_y = 100
300
+ name_y = dates_y + 36 + 6 # Ano + espaçamento de 6px + nome
301
+ else: # bottom
302
+ dates_y = height - 250
303
+ name_y = dates_y + 36 + 6 # Ano + espaçamento de 6px + nome
304
+
305
+ # Desenhar datas primeiro (se fornecidas)
306
+ if birth or death:
307
+ font_path_regular = "fonts/AGaramondPro-Regular.ttf"
308
+
309
+ # Construir texto das datas
310
+ dates_text = ""
311
+ if birth and death:
312
+ dates_text = f"{birth} - {death}"
313
+ elif birth:
314
+ dates_text = f"{birth}"
315
+ elif death:
316
+ dates_text = f"- {death}"
317
+
318
+ if dates_text:
319
+ draw_text_left_aligned(draw, dates_text, text_x, dates_y, font_path_regular, 36)
320
+
321
+ # Desenhar nome abaixo das datas
322
+ if name:
323
+ font_path = "fonts/AGaramondPro-BoldItalic.ttf"
324
+ draw_text_left_aligned(draw, name, text_x, name_y, font_path, 87)
325
+
326
+ buffer = BytesIO()
327
+ canvas.save(buffer, format="PNG")
328
+ buffer.seek(0)
329
+ return buffer
330
+
331
+ @router.get("/search/wikipedia")
332
+ def search_wikipedia_names(
333
+ name: str = Query(..., description="Nome para buscar na Wikipedia")
334
+ ):
335
+ """
336
+ Busca nomes na Wikipedia e retorna lista com foto, nome completo e wikibase_item.
337
+ Retorna até 5 resultados ordenados por relevância.
338
+ """
339
+ if not name or len(name.strip()) < 2:
340
+ raise HTTPException(status_code=400, detail="Nome deve ter pelo menos 2 caracteres")
341
+
342
+ try:
343
+ results = search_wikipedia(name.strip())
344
+ return {
345
+ "query": name,
346
+ "results": results,
347
+ "total": len(results)
348
+ }
349
+ except Exception as e:
350
+ log.error(f"Erro na busca: {e}")
351
+ raise HTTPException(status_code=500, detail=f"Erro ao buscar: {str(e)}")
352
+
353
+ @router.get("/wikidata/dates")
354
+ def get_wikidata_person_info(
355
+ wikibase_item: str = Query(..., description="ID do Wikidata (ex: Q10304982)"),
356
+ name: Optional[str] = Query(None, description="Nome da pessoa (opcional)"),
357
+ image_url: Optional[str] = Query(None, description="URL da imagem (opcional, padrão: placeholder)")
358
+ ):
359
+ """
360
+ Consulta o Wikidata para obter datas de nascimento e falecimento,
361
+ e retorna URL formatada para o endpoint de memoriam.
362
+ Se death_year for null/vazio, usa o ano atual por padrão.
363
+ """
364
+ if not wikibase_item or not wikibase_item.startswith('Q'):
365
+ raise HTTPException(status_code=400, detail="wikibase_item deve ser um ID válido do Wikidata (ex: Q10304982)")
366
+
367
+ try:
368
+ # Obter datas do Wikidata
369
+ dates = get_wikidata_dates(wikibase_item)
370
+ birth_year = dates.get("birth")
371
+ death_year = dates.get("death")
372
+
373
+ # Se death_year estiver vazio/null, usar o ano atual
374
+ if not death_year:
375
+ death_year = str(datetime.now().year)
376
+
377
+ # Usar placeholder como padrão se image_url não fornecida
378
+ if not image_url:
379
+ image_url = "https://placehold.co/1080x1350.png"
380
+
381
+ # Construir URL do memoriam
382
+ base_url = "https://habulaj-newapi-clone2.hf.space/cover/memoriam"
383
+ params = []
384
+
385
+ if name:
386
+ params.append(f"name={quote(name)}")
387
+
388
+ if birth_year:
389
+ params.append(f"birth={birth_year}")
390
+
391
+ if death_year:
392
+ params.append(f"death={death_year}")
393
+
394
+ if image_url:
395
+ params.append(f"image_url={quote(image_url)}")
396
+
397
+ # Sempre adicionar text_position=bottom como padrão
398
+ params.append("text_position=bottom")
399
+
400
+ # Montar URL final
401
+ memoriam_url = base_url + "?" + "&".join(params)
402
+
403
+ return {
404
+ "wikibase_item": wikibase_item,
405
+ "name": name,
406
+ "image_url": image_url,
407
+ "birth_year": birth_year,
408
+ "death_year": death_year,
409
+ "memoriam_url": memoriam_url,
410
+ "dates_found": {
411
+ "birth": birth_year is not None,
412
+ "death": dates.get("death") is not None # Original death from Wikidata
413
+ },
414
+ "death_year_source": "wikidata" if dates.get("death") else "current_year"
415
+ }
416
+
417
+ except Exception as e:
418
+ log.error(f"Erro ao processar informações: {e}")
419
+ raise HTTPException(status_code=500, detail=f"Erro ao processar: {str(e)}")
420
+
421
+ @router.get("/cover/memoriam")
422
+ def get_memoriam_image(
423
+ image_url: Optional[str] = Query(None, description="URL da imagem de fundo"),
424
+ name: Optional[str] = Query(None, description="Nome (será exibido em maiúsculas)"),
425
+ birth: Optional[str] = Query(None, description="Ano de nascimento (ex: 1943)"),
426
+ death: Optional[str] = Query(None, description="Ano de falecimento (ex: 2023)"),
427
+ text_position: str = Query("bottom", description="Posição do texto: 'top' ou 'bottom'")
428
+ ):
429
+ """
430
+ Gera imagem de memoriam no formato 1080x1350 (Instagram).
431
+ Todos os parâmetros são opcionais, mas recomenda-se fornecer pelo menos o nome.
432
+ O gradiente será aplicado baseado na posição do texto (top ou bottom).
433
+ """
434
+ try:
435
+ buffer = create_canvas(image_url, name, birth, death, text_position)
436
+ return StreamingResponse(buffer, media_type="image/png")
437
+ except Exception as e:
438
+ raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}")
routers/news.py ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, List, Union
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_collage_background(image_urls: List[str], canvas_width: int, canvas_height: int, device: str) -> Image.Image:
46
+ """Cria uma colagem como fundo baseada na lista de URLs"""
47
+ num_images = len(image_urls)
48
+ border_size = 4 if num_images > 1 else 0 # Linha mais fina e elegante
49
+ is_web = device.lower() == "web"
50
+
51
+ images = [download_image_from_url(url) for url in image_urls]
52
+ canvas = Image.new("RGBA", (canvas_width, canvas_height), (255, 255, 255, 255))
53
+
54
+ if num_images == 1:
55
+ img = resize_and_crop_to_fill(images[0], canvas_width, canvas_height)
56
+ canvas.paste(img, (0, 0))
57
+
58
+ elif num_images == 2:
59
+ # Ambos dispositivos: lado a lado
60
+ slot_width = (canvas_width - border_size) // 2
61
+ img1 = resize_and_crop_to_fill(images[0], slot_width, canvas_height)
62
+ img2 = resize_and_crop_to_fill(images[1], slot_width, canvas_height)
63
+ canvas.paste(img1, (0, 0))
64
+ canvas.paste(img2, (slot_width + border_size, 0))
65
+
66
+ elif num_images == 3:
67
+ if is_web:
68
+ # Web: 1 grande à esquerda, 2 pequenas empilhadas à direita
69
+ left_width = (canvas_width - border_size) * 2 // 3
70
+ right_width = canvas_width - left_width - border_size
71
+ half_height = (canvas_height - border_size) // 2
72
+
73
+ img1 = resize_and_crop_to_fill(images[0], left_width, canvas_height)
74
+ img2 = resize_and_crop_to_fill(images[1], right_width, half_height)
75
+ img3 = resize_and_crop_to_fill(images[2], right_width, half_height)
76
+
77
+ canvas.paste(img1, (0, 0))
78
+ canvas.paste(img2, (left_width + border_size, 0))
79
+ canvas.paste(img3, (left_width + border_size, half_height + border_size))
80
+ else:
81
+ # IG: layout original
82
+ half_height = (canvas_height - border_size) // 2
83
+ half_width = (canvas_width - border_size) // 2
84
+ img1 = resize_and_crop_to_fill(images[0], half_width, half_height)
85
+ img2 = resize_and_crop_to_fill(images[1], half_width, half_height)
86
+ img3 = resize_and_crop_to_fill(images[2], canvas_width, half_height)
87
+ canvas.paste(img1, (0, 0))
88
+ canvas.paste(img2, (half_width + border_size, 0))
89
+ canvas.paste(img3, (0, half_height + border_size))
90
+
91
+ elif num_images == 4:
92
+ if is_web:
93
+ # Web: 4 imagens lado a lado horizontalmente
94
+ slot_width = (canvas_width - 3 * border_size) // 4
95
+ for i in range(4):
96
+ img = resize_and_crop_to_fill(images[i], slot_width, canvas_height)
97
+ x_pos = i * (slot_width + border_size)
98
+ canvas.paste(img, (x_pos, 0))
99
+ else:
100
+ # IG: Layout 2x2
101
+ half_height = (canvas_height - border_size) // 2
102
+ half_width = (canvas_width - border_size) // 2
103
+ img1 = resize_and_crop_to_fill(images[0], half_width, half_height)
104
+ img2 = resize_and_crop_to_fill(images[1], half_width, half_height)
105
+ img3 = resize_and_crop_to_fill(images[2], half_width, half_height)
106
+ img4 = resize_and_crop_to_fill(images[3], half_width, half_height)
107
+ canvas.paste(img1, (0, 0))
108
+ canvas.paste(img2, (half_width + border_size, 0))
109
+ canvas.paste(img3, (0, half_height + border_size))
110
+ canvas.paste(img4, (half_width + border_size, half_height + border_size))
111
+
112
+ elif num_images == 5:
113
+ if is_web:
114
+ # Web: 3 em cima, 2 embaixo
115
+ top_height = (canvas_height - border_size) * 3 // 5
116
+ bottom_height = canvas_height - top_height - border_size
117
+
118
+ available_width = canvas_width - 2 * border_size
119
+ third_width = available_width // 3
120
+ third_width_last = canvas_width - (third_width * 2 + border_size * 2)
121
+ half_width = (canvas_width - border_size) // 2
122
+
123
+ # 3 imagens em cima
124
+ img1 = resize_and_crop_to_fill(images[0], third_width, top_height)
125
+ img2 = resize_and_crop_to_fill(images[1], third_width, top_height)
126
+ img3 = resize_and_crop_to_fill(images[2], third_width_last, top_height)
127
+ canvas.paste(img1, (0, 0))
128
+ canvas.paste(img2, (third_width + border_size, 0))
129
+ canvas.paste(img3, (third_width * 2 + border_size * 2, 0))
130
+
131
+ # 2 imagens embaixo
132
+ y_offset = top_height + border_size
133
+ img4 = resize_and_crop_to_fill(images[3], half_width, bottom_height)
134
+ img5 = resize_and_crop_to_fill(images[4], half_width, bottom_height)
135
+ canvas.paste(img4, (0, y_offset))
136
+ canvas.paste(img5, (half_width + border_size, y_offset))
137
+ else:
138
+ # IG: layout original
139
+ top_height = (canvas_height - border_size) * 2 // 5
140
+ bottom_height = canvas_height - top_height - border_size
141
+ half_width = (canvas_width - border_size) // 2
142
+
143
+ img1 = resize_and_crop_to_fill(images[0], half_width, top_height)
144
+ img2 = resize_and_crop_to_fill(images[1], half_width, top_height)
145
+ canvas.paste(img1, (0, 0))
146
+ canvas.paste(img2, (half_width + border_size, 0))
147
+
148
+ y_offset = top_height + border_size
149
+ third_width = (canvas_width - 2 * border_size) // 3
150
+ third_width_last = canvas_width - (third_width * 2 + border_size * 2)
151
+
152
+ img3 = resize_and_crop_to_fill(images[2], third_width, bottom_height)
153
+ img4 = resize_and_crop_to_fill(images[3], third_width, bottom_height)
154
+ img5 = resize_and_crop_to_fill(images[4], third_width_last, bottom_height)
155
+ canvas.paste(img3, (0, y_offset))
156
+ canvas.paste(img4, (third_width + border_size, y_offset))
157
+ canvas.paste(img5, (third_width * 2 + border_size * 2, y_offset))
158
+
159
+ elif num_images == 6:
160
+ if is_web:
161
+ # Web: 3x2 (3 colunas, 2 linhas)
162
+ half_height = (canvas_height - border_size) // 2
163
+ available_width = canvas_width - 2 * border_size
164
+ third_width = available_width // 3
165
+ third_width_last = canvas_width - (third_width * 2 + border_size * 2)
166
+
167
+ # Primeira linha
168
+ img1 = resize_and_crop_to_fill(images[0], third_width, half_height)
169
+ img2 = resize_and_crop_to_fill(images[1], third_width, half_height)
170
+ img3 = resize_and_crop_to_fill(images[2], third_width_last, half_height)
171
+ canvas.paste(img1, (0, 0))
172
+ canvas.paste(img2, (third_width + border_size, 0))
173
+ canvas.paste(img3, (third_width * 2 + border_size * 2, 0))
174
+
175
+ # Segunda linha
176
+ y_offset = half_height + border_size
177
+ img4 = resize_and_crop_to_fill(images[3], third_width, half_height)
178
+ img5 = resize_and_crop_to_fill(images[4], third_width, half_height)
179
+ img6 = resize_and_crop_to_fill(images[5], third_width_last, half_height)
180
+ canvas.paste(img4, (0, y_offset))
181
+ canvas.paste(img5, (third_width + border_size, y_offset))
182
+ canvas.paste(img6, (third_width * 2 + border_size * 2, y_offset))
183
+ else:
184
+ # IG: layout original (3x2)
185
+ half_height = (canvas_height - border_size) // 2
186
+ third_width = (canvas_width - 2 * border_size) // 3
187
+ third_width_last = canvas_width - (third_width * 2 + border_size * 2)
188
+
189
+ # Primeira linha
190
+ img1 = resize_and_crop_to_fill(images[0], third_width, half_height)
191
+ img2 = resize_and_crop_to_fill(images[1], third_width, half_height)
192
+ img3 = resize_and_crop_to_fill(images[2], third_width, half_height)
193
+ canvas.paste(img1, (0, 0))
194
+ canvas.paste(img2, (third_width + border_size, 0))
195
+ canvas.paste(img3, (third_width * 2 + border_size * 2, 0))
196
+
197
+ # Segunda linha
198
+ y_offset = half_height + border_size
199
+ img4 = resize_and_crop_to_fill(images[3], third_width, half_height)
200
+ img5 = resize_and_crop_to_fill(images[4], third_width, half_height)
201
+ img6 = resize_and_crop_to_fill(images[5], third_width_last, half_height)
202
+ canvas.paste(img4, (0, y_offset))
203
+ canvas.paste(img5, (third_width + border_size, y_offset))
204
+ canvas.paste(img6, (third_width * 2 + border_size * 2, y_offset))
205
+
206
+ else:
207
+ raise HTTPException(status_code=400, detail="Apenas até 6 imagens são suportadas.")
208
+
209
+ return canvas
210
+
211
+ def parse_image_urls(image_url_param: Union[str, List[str]]) -> List[str]:
212
+ """Converte o parâmetro de URL(s) em lista de URLs"""
213
+ if isinstance(image_url_param, list):
214
+ return image_url_param
215
+ elif isinstance(image_url_param, str):
216
+ # Se contém vírgulas, divide em múltiplas URLs
217
+ if ',' in image_url_param:
218
+ return [url.strip() for url in image_url_param.split(',') if url.strip()]
219
+ else:
220
+ return [image_url_param]
221
+ return []
222
+
223
+ def create_gradient_overlay(width: int, height: int, text_position: str = "bottom") -> Image.Image:
224
+ """
225
+ Cria gradiente overlay baseado na posição do texto
226
+ """
227
+ gradient = Image.new("RGBA", (width, height))
228
+ draw = ImageDraw.Draw(gradient)
229
+
230
+ if text_position.lower() == "bottom":
231
+ # Gradiente para texto embaixo: posição Y:531, altura 835px
232
+ gradient_start = 531
233
+ gradient_height = 835
234
+
235
+ for y in range(gradient_height):
236
+ if y + gradient_start < height:
237
+ # Gradient: 0% transparent -> 46.63% rgba(0,0,0,0.55) -> 100% rgba(0,0,0,0.7)
238
+ ratio = y / gradient_height
239
+ if ratio <= 0.4663:
240
+ # 0% a 46.63%: de transparente para 0.55
241
+ opacity_ratio = ratio / 0.4663
242
+ opacity = int(255 * 0.55 * opacity_ratio)
243
+ else:
244
+ # 46.63% a 100%: de 0.55 para 0.7
245
+ opacity_ratio = (ratio - 0.4663) / (1 - 0.4663)
246
+ opacity = int(255 * (0.55 + (0.7 - 0.55) * opacity_ratio))
247
+
248
+ draw.line([(0, y + gradient_start), (width, y + gradient_start)], fill=(0, 0, 0, opacity))
249
+
250
+ else: # text_position == "top"
251
+ # Gradiente para texto no topo: posição Y:0, altura 835px
252
+ # 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%)
253
+ # 0deg significa: 0% = bottom, 100% = top
254
+ gradient_height = 835
255
+
256
+ for y in range(gradient_height):
257
+ if y < height:
258
+ # Inverter a ratio: y=0 (topo) deve ser 100% do gradient, y=835 (bottom) deve ser 0%
259
+ ratio = (gradient_height - y) / gradient_height
260
+ if ratio <= 0.1693:
261
+ # 0% a 16.93%: de 0 (transparente) para 0.307
262
+ opacity_ratio = ratio / 0.1693
263
+ opacity = int(255 * (0.307 * opacity_ratio))
264
+ elif ratio <= 0.4557:
265
+ # 16.93% a 45.57%: de 0.307 para 0.55
266
+ opacity_ratio = (ratio - 0.1693) / (0.4557 - 0.1693)
267
+ opacity = int(255 * (0.307 + (0.55 - 0.307) * opacity_ratio))
268
+ else:
269
+ # 45.57% a 100%: de 0.55 para 0.7
270
+ opacity_ratio = (ratio - 0.4557) / (1 - 0.4557)
271
+ opacity = int(255 * (0.55 + (0.7 - 0.55) * opacity_ratio))
272
+
273
+ draw.line([(0, y), (width, y)], fill=(0, 0, 0, opacity))
274
+
275
+ return gradient
276
+
277
+ def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> list[str]:
278
+ words = text.split()
279
+ lines = []
280
+ current_line = ""
281
+
282
+ for word in words:
283
+ test_line = f"{current_line} {word}".strip()
284
+ if draw.textlength(test_line, font=font) <= max_width:
285
+ current_line = test_line
286
+ else:
287
+ if current_line:
288
+ lines.append(current_line)
289
+ current_line = word
290
+ if current_line:
291
+ lines.append(current_line)
292
+ return lines
293
+
294
+ def get_responsive_font_and_lines(text: str, font_path: str, max_width: int, max_lines: int = 3,
295
+ max_font_size: int = 80, min_font_size: int = 20) -> tuple[ImageFont.FreeTypeFont, list[str], int]:
296
+ """
297
+ Retorna a fonte e linhas ajustadas para caber no número máximo de linhas.
298
+ """
299
+ temp_img = Image.new("RGB", (1, 1))
300
+ temp_draw = ImageDraw.Draw(temp_img)
301
+
302
+ current_font_size = max_font_size
303
+
304
+ while current_font_size >= min_font_size:
305
+ try:
306
+ font = ImageFont.truetype(font_path, current_font_size)
307
+ except Exception:
308
+ font = ImageFont.load_default()
309
+
310
+ lines = wrap_text(text, font, max_width, temp_draw)
311
+
312
+ if len(lines) <= max_lines:
313
+ return font, lines, current_font_size
314
+
315
+ current_font_size -= 1
316
+
317
+ try:
318
+ font = ImageFont.truetype(font_path, min_font_size)
319
+ except Exception:
320
+ font = ImageFont.load_default()
321
+
322
+ lines = wrap_text(text, font, max_width, temp_draw)
323
+ return font, lines, min_font_size
324
+
325
+ def get_font_and_lines(text: str, font_path: str, font_size: int, max_width: int) -> tuple[ImageFont.FreeTypeFont, list[str]]:
326
+ """
327
+ Retorna a fonte e linhas com tamanho fixo de fonte.
328
+ """
329
+ try:
330
+ font = ImageFont.truetype(font_path, font_size)
331
+ except Exception:
332
+ font = ImageFont.load_default()
333
+
334
+ temp_img = Image.new("RGB", (1, 1))
335
+ temp_draw = ImageDraw.Draw(temp_img)
336
+ lines = wrap_text(text, font, max_width, temp_draw)
337
+
338
+ return font, lines
339
+
340
+ def get_text_color_rgb(text_color: str) -> tuple[int, int, int]:
341
+ """
342
+ Converte o parâmetro text_color para RGB.
343
+ """
344
+ if text_color.lower() == "black":
345
+ return (0, 0, 0)
346
+ else: # white por padrão
347
+ return (255, 255, 255)
348
+
349
+ def get_device_dimensions(device: str) -> tuple[int, int]:
350
+ """Retorna as dimensões baseadas no dispositivo"""
351
+ if device.lower() == "web":
352
+ return (1280, 720)
353
+ else: # Instagram por padrão
354
+ return (1080, 1350)
355
+
356
+ def create_canvas(image_url: Optional[Union[str, List[str]]], headline: Optional[str], device: str = "ig",
357
+ text_position: str = "bottom", text_color: str = "white") -> BytesIO:
358
+ width, height = get_device_dimensions(device)
359
+ is_web = device.lower() == "web"
360
+ text_rgb = get_text_color_rgb(text_color)
361
+
362
+ # Configurações específicas por dispositivo
363
+ if is_web:
364
+ padding_x = 40
365
+ logo_width, logo_height = 120, 22
366
+ logo_padding = 40
367
+ else:
368
+ padding_x = 60
369
+ bottom_padding = 80
370
+ top_padding = 60
371
+ logo_width, logo_height = 121, 23 # Novas dimensões: L:121, A:22.75 (arredondado para 23)
372
+
373
+ max_width = width - 2 * padding_x
374
+
375
+ canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255))
376
+
377
+ # Adicionar imagem(s) de fundo se fornecida(s)
378
+ if image_url:
379
+ parsed_urls = parse_image_urls(image_url)
380
+ if parsed_urls:
381
+ if len(parsed_urls) > 6:
382
+ raise HTTPException(status_code=400, detail="Máximo de 6 imagens permitidas")
383
+
384
+ if len(parsed_urls) == 1:
385
+ # Uma única imagem - comportamento original
386
+ img = download_image_from_url(parsed_urls[0])
387
+ filled_img = resize_and_crop_to_fill(img, width, height)
388
+ canvas.paste(filled_img, (0, 0))
389
+ else:
390
+ # Múltiplas imagens - criar colagem
391
+ canvas = create_collage_background(parsed_urls, width, height, device)
392
+
393
+ # Para Instagram: adicionar gradiente e texto
394
+ if not is_web:
395
+ # Só aplicar gradiente se o texto for branco
396
+ if text_color.lower() != "black":
397
+ gradient_overlay = create_gradient_overlay(width, height, text_position)
398
+ canvas = Image.alpha_composite(canvas, gradient_overlay)
399
+
400
+ if headline:
401
+ draw = ImageDraw.Draw(canvas)
402
+ font_path = "fonts/AGaramondPro-Semibold.ttf"
403
+ line_height_factor = 1.05 # 105% da altura da linha
404
+
405
+ try:
406
+ font, lines, font_size = get_responsive_font_and_lines(
407
+ headline, font_path, max_width, max_lines=3,
408
+ max_font_size=80, min_font_size=20
409
+ )
410
+ line_height = int(font_size * line_height_factor)
411
+
412
+ except Exception as e:
413
+ raise HTTPException(status_code=500, detail=f"Erro ao processar a fonte: {e}")
414
+
415
+ total_text_height = len(lines) * line_height
416
+
417
+ # Posicionar texto baseado no parâmetro text_position
418
+ if text_position.lower() == "bottom":
419
+ # Posicionar texto 50px acima da logo (que está em Y:1274)
420
+ text_end_y = 1274 - 50
421
+ start_y = text_end_y - total_text_height
422
+ else: # text_position == "top"
423
+ # Posicionar texto no topo com padding
424
+ start_y = top_padding
425
+
426
+ # Adicionar logo no canto inferior direito (posição fixa)
427
+ try:
428
+ logo_path = "recurve.png"
429
+ logo = Image.open(logo_path).convert("RGBA")
430
+ logo_resized = logo.resize((logo_width, logo_height))
431
+
432
+ # Aplicar opacidade de 42%
433
+ logo_with_opacity = Image.new("RGBA", logo_resized.size)
434
+ for x in range(logo_resized.width):
435
+ for y in range(logo_resized.height):
436
+ r, g, b, a = logo_resized.getpixel((x, y))
437
+ new_alpha = int(a * 0.42) # 42% de opacidade
438
+ logo_with_opacity.putpixel((x, y), (r, g, b, new_alpha))
439
+
440
+ # Posição fixa: X:891, Y:1274
441
+ canvas.paste(logo_with_opacity, (891, 1274), logo_with_opacity)
442
+ except Exception as e:
443
+ raise HTTPException(status_code=500, detail=f"Erro ao carregar a logo: {e}")
444
+
445
+ # Adiciona texto com a cor especificada
446
+ for i, line in enumerate(lines):
447
+ y = start_y + i * line_height
448
+ draw.text((padding_x, y), line, font=font, fill=text_rgb)
449
+
450
+ # Para web: apenas logo no canto inferior direito
451
+ else:
452
+ try:
453
+ logo_path = "recurve.png"
454
+ logo = Image.open(logo_path).convert("RGBA")
455
+ logo_resized = logo.resize((logo_width, logo_height))
456
+ logo_x = width - logo_width - logo_padding
457
+ logo_y = height - logo_height - logo_padding
458
+ canvas.paste(logo_resized, (logo_x, logo_y), logo_resized)
459
+ except Exception as e:
460
+ raise HTTPException(status_code=500, detail=f"Erro ao carregar a logo: {e}")
461
+
462
+ buffer = BytesIO()
463
+ canvas.convert("RGB").save(buffer, format="PNG")
464
+ buffer.seek(0)
465
+ return buffer
466
+
467
+ @router.get("/cover/news")
468
+ def get_news_image(
469
+ image_url: Optional[Union[str, List[str]]] = Query(None, description="URL da imagem ou lista de URLs separadas por vírgula para colagem (máximo 6)"),
470
+ headline: Optional[str] = Query(None, description="Texto do título (opcional para IG, ignorado para web)"),
471
+ device: str = Query("ig", description="Dispositivo: 'ig' para Instagram (1080x1350) ou 'web' para Web (1280x720)"),
472
+ text_position: str = Query("bottom", description="Posição do texto: 'top' para topo ou 'bottom' para parte inferior"),
473
+ text_color: str = Query("white", description="Cor do texto: 'white' (padrão) ou 'black'. Se 'black', remove o gradiente de fundo")
474
+ ):
475
+ try:
476
+ buffer = create_canvas(image_url, headline, device, text_position, text_color)
477
+ return StreamingResponse(buffer, media_type="image/png")
478
+ except Exception as e:
479
+ raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}")
routers/search.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Query
2
+ from fastapi.responses import JSONResponse
3
+ import httpx
4
+ import os
5
+ import json
6
+ import re
7
+ from urllib.parse import unquote
8
+ from PIL import Image
9
+ import io
10
+ import asyncio
11
+ import struct
12
+ from typing import Optional, Tuple, List, Dict
13
+ import base64
14
+ from functools import lru_cache
15
+ import aiofiles
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ import time
18
+
19
+ router = APIRouter()
20
+
21
+ # Pool de threads otimizado para operações CPU-intensivas (thumbnail)
22
+ thumbnail_executor = ThreadPoolExecutor(
23
+ max_workers=min(32, (os.cpu_count() or 1) + 4),
24
+ thread_name_prefix="thumbnail_"
25
+ )
26
+
27
+ # Cache em memória para URLs já processadas
28
+ _url_cache = {}
29
+ _cache_max_size = 1000
30
+
31
+ @router.get("/search")
32
+ async def search(
33
+ q: str = Query(..., description="Termo de pesquisa para imagens"),
34
+ min_width: int = Query(1200, description="Largura mínima das imagens (padrão: 1200px)"),
35
+ include_thumbnails: bool = Query(True, description="Incluir miniaturas base64 nas respostas")
36
+ ):
37
+ """
38
+ Busca imagens no Google Imagens com máxima performance
39
+ """
40
+ start_time = time.time()
41
+
42
+ # URL do Google Imagens com parâmetros para imagens grandes
43
+ google_images_url = "http://www.google.com/search"
44
+
45
+ params = {
46
+ "tbm": "isch",
47
+ "q": q,
48
+ "start": 0,
49
+ "sa": "N",
50
+ "asearch": "arc",
51
+ "cs": "1",
52
+ "tbs": "isz:l",
53
+ "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"
54
+ }
55
+
56
+ headers = {
57
+ "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",
58
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
59
+ "Accept-Language": "pt-BR,pt;q=0.8,en-US;q=0.5,en;q=0.3",
60
+ "Accept-Encoding": "gzip, deflate",
61
+ "Connection": "keep-alive",
62
+ "Referer": "https://www.google.com/"
63
+ }
64
+
65
+ try:
66
+ # Busca no Google (rápida)
67
+ async with httpx.AsyncClient(timeout=30.0) as client:
68
+ response = await client.get(google_images_url, params=params, headers=headers)
69
+
70
+ if response.status_code != 200:
71
+ raise HTTPException(status_code=response.status_code, detail="Erro ao buscar no Google Imagens")
72
+
73
+ print(f"Google respondeu em {time.time() - start_time:.2f}s")
74
+ extract_start = time.time()
75
+
76
+ # Extração otimizada
77
+ images = extract_images_from_response_optimized(response.text)
78
+ print(f"Extração concluída em {time.time() - extract_start:.2f}s - {len(images)} URLs")
79
+
80
+ # Processamento paralelo massivo
81
+ processing_start = time.time()
82
+ enriched_images = await enrich_images_ultra_fast(images, include_thumbnails)
83
+ print(f"Processamento concluído em {time.time() - processing_start:.2f}s")
84
+
85
+ # Filtragem rápida
86
+ valid_images = [
87
+ img for img in enriched_images
88
+ if img.get('width', 0) >= min_width and img.get('height', 0) > 0
89
+ ]
90
+
91
+ # Se poucos resultados, busca adicional em paralelo
92
+ if len(valid_images) < 20:
93
+ params["tbs"] = "isz:lt,islt:4mp"
94
+
95
+ async with httpx.AsyncClient(timeout=30.0) as client:
96
+ response2 = await client.get(google_images_url, params=params, headers=headers)
97
+
98
+ if response2.status_code == 200:
99
+ additional_images = extract_images_from_response_optimized(response2.text)
100
+ additional_enriched = await enrich_images_ultra_fast(additional_images, include_thumbnails)
101
+
102
+ # Merge rápido com set para deduplicação
103
+ seen_urls = {img.get('url') for img in valid_images}
104
+ for img in additional_enriched:
105
+ if (img.get('url') not in seen_urls
106
+ and img.get('width', 0) >= min_width
107
+ and img.get('height', 0) > 0):
108
+ valid_images.append(img)
109
+ seen_urls.add(img.get('url'))
110
+
111
+ # Ordenação e limitação
112
+ valid_images.sort(key=lambda x: x.get('width', 0), reverse=True)
113
+ final_images = valid_images[:50]
114
+
115
+ total_time = time.time() - start_time
116
+ print(f"TEMPO TOTAL: {total_time:.2f}s - {len(final_images)} imagens finais")
117
+
118
+ return JSONResponse(content={
119
+ "query": q,
120
+ "min_width_filter": min_width,
121
+ "total_found": len(final_images),
122
+ "thumbnails_included": include_thumbnails,
123
+ "processing_time": round(total_time, 2),
124
+ "images": final_images
125
+ })
126
+
127
+ except httpx.TimeoutException:
128
+ raise HTTPException(status_code=408, detail="Timeout na requisição ao Google")
129
+ except Exception as e:
130
+ raise HTTPException(status_code=500, detail=f"Erro ao executar a busca: {str(e)}")
131
+
132
+
133
+ @lru_cache(maxsize=500)
134
+ def clean_wikimedia_url_cached(url: str) -> str:
135
+ """
136
+ Versão cached da limpeza de URLs do Wikimedia
137
+ """
138
+ if 'wikimedia.org' in url and '/thumb/' in url:
139
+ try:
140
+ parts = url.split('/thumb/')
141
+ if len(parts) == 2:
142
+ before_thumb = parts[0]
143
+ after_thumb = parts[1]
144
+ path_parts = after_thumb.split('/')
145
+
146
+ if len(path_parts) >= 3:
147
+ original_path = '/'.join(path_parts[:3])
148
+ return f"{before_thumb}/{original_path}"
149
+ except:
150
+ pass
151
+ return url
152
+
153
+
154
+ def extract_images_from_response_optimized(response_text: str) -> List[Dict]:
155
+ """
156
+ Extração ultra-otimizada usando regex compilado e processamento em lote
157
+ """
158
+ # Regex compilado (mais rápido)
159
+ pattern = re.compile(r'https?://[^\s"\'<>]+?\.(?:jpg|png|webp|jpeg)\b', re.IGNORECASE)
160
+
161
+ # Extração em uma única passada
162
+ image_urls = pattern.findall(response_text)
163
+
164
+ # Deduplicação com set (O(1) lookup)
165
+ seen_urls = set()
166
+ images = []
167
+
168
+ # Processa URLs em lote
169
+ for url in image_urls[:200]: # Aumentado para compensar filtragem
170
+ cleaned_url = clean_wikimedia_url_cached(url)
171
+ if cleaned_url not in seen_urls:
172
+ seen_urls.add(cleaned_url)
173
+ images.append({"url": cleaned_url, "width": None, "height": None})
174
+
175
+ return images
176
+
177
+
178
+ def get_image_size_super_fast(data: bytes) -> Optional[Tuple[int, int]]:
179
+ """
180
+ Parsing ultra-otimizado - apenas formatos mais comuns primeiro
181
+ """
182
+ if len(data) < 24:
183
+ return None
184
+
185
+ try:
186
+ # JPEG (mais comum) - otimizado
187
+ if data[:2] == b'\xff\xd8':
188
+ # Busca mais eficiente pelos markers
189
+ for i in range(2, min(len(data) - 8, 1000)): # Limita busca
190
+ if data[i:i+2] in (b'\xff\xc0', b'\xff\xc2'):
191
+ if i + 9 <= len(data):
192
+ height = struct.unpack('>H', data[i+5:i+7])[0]
193
+ width = struct.unpack('>H', data[i+7:i+9])[0]
194
+ if width > 0 and height > 0:
195
+ return width, height
196
+
197
+ # PNG (segundo mais comum)
198
+ elif data[:8] == b'\x89PNG\r\n\x1a\n' and len(data) >= 24:
199
+ width = struct.unpack('>I', data[16:20])[0]
200
+ height = struct.unpack('>I', data[20:24])[0]
201
+ if width > 0 and height > 0:
202
+ return width, height
203
+
204
+ # WebP (crescimento)
205
+ elif data[:12] == b'RIFF' + data[4:8] + b'WEBP' and len(data) >= 30:
206
+ if data[12:16] == b'VP8 ':
207
+ width = struct.unpack('<H', data[26:28])[0] & 0x3fff
208
+ height = struct.unpack('<H', data[28:30])[0] & 0x3fff
209
+ if width > 0 and height > 0:
210
+ return width, height
211
+ except:
212
+ pass
213
+
214
+ return None
215
+
216
+
217
+ def create_thumbnail_cpu_optimized(image_data: bytes, max_size: int = 200) -> Optional[str]:
218
+ """
219
+ Versão CPU-otimizada para threading
220
+ """
221
+ if not image_data or len(image_data) < 100:
222
+ return None
223
+
224
+ try:
225
+ # Abre imagem (rápido)
226
+ with Image.open(io.BytesIO(image_data)) as image:
227
+ # Conversão rápida para RGB
228
+ if image.mode != 'RGB':
229
+ if image.mode in ('RGBA', 'LA'):
230
+ # Background branco para transparências
231
+ bg = Image.new('RGB', image.size, (255, 255, 255))
232
+ bg.paste(image, mask=image.split()[-1] if 'A' in image.mode else None)
233
+ image = bg
234
+ else:
235
+ image = image.convert('RGB')
236
+
237
+ # Cálculo otimizado de proporções
238
+ w, h = image.size
239
+ if w > h:
240
+ new_w, new_h = max_size, max(1, (h * max_size) // w)
241
+ else:
242
+ new_w, new_h = max(1, (w * max_size) // h), max_size
243
+
244
+ # Resize com filtro mais rápido para thumbnails
245
+ thumbnail = image.resize((new_w, new_h), Image.Resampling.BILINEAR)
246
+
247
+ # Salva com configurações otimizadas
248
+ buffer = io.BytesIO()
249
+ thumbnail.save(buffer, format='JPEG', quality=80, optimize=False) # optimize=False é mais rápido
250
+
251
+ return f"data:image/jpeg;base64,{base64.b64encode(buffer.getvalue()).decode('utf-8')}"
252
+
253
+ except Exception as e:
254
+ return None
255
+
256
+
257
+ async def download_and_process_image(session: httpx.AsyncClient, url: str, include_thumbnail: bool) -> Dict:
258
+ """
259
+ Download e processamento otimizado de uma única imagem
260
+ """
261
+ # Verifica cache primeiro
262
+ cache_key = f"{url}_{include_thumbnail}"
263
+ if cache_key in _url_cache:
264
+ return _url_cache[cache_key].copy()
265
+
266
+ clean_url = url.replace('\\u003d', '=').replace('\\u0026', '&').replace('\\\\', '').replace('\\/', '/')
267
+
268
+ headers = {
269
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
270
+ 'Accept': 'image/*',
271
+ 'Connection': 'close'
272
+ }
273
+
274
+ width, height, thumbnail_b64 = None, None, None
275
+
276
+ try:
277
+ # Estratégia otimizada: tamanhos incrementais
278
+ ranges = ['0-8192', '0-32768', '0-131072'] if include_thumbnail else ['0-2048']
279
+
280
+ for range_header in ranges:
281
+ headers['Range'] = f'bytes={range_header}'
282
+
283
+ try:
284
+ response = await session.get(clean_url, headers=headers, timeout=6.0)
285
+ if response.status_code in [200, 206] and len(response.content) > 100:
286
+
287
+ # Parsing rápido de dimensões
288
+ if not width or not height:
289
+ dimensions = get_image_size_super_fast(response.content)
290
+ if dimensions:
291
+ width, height = dimensions
292
+
293
+ # Thumbnail em thread separada se necessário
294
+ if include_thumbnail and not thumbnail_b64:
295
+ loop = asyncio.get_event_loop()
296
+ thumbnail_b64 = await loop.run_in_executor(
297
+ thumbnail_executor,
298
+ create_thumbnail_cpu_optimized,
299
+ response.content
300
+ )
301
+
302
+ # Se conseguiu tudo o que precisava, para por aqui
303
+ if width and height and (not include_thumbnail or thumbnail_b64):
304
+ break
305
+
306
+ except:
307
+ continue # Tenta próximo range
308
+
309
+ # Fallback final: download completo se necessário
310
+ if (not width or not height or (include_thumbnail and not thumbnail_b64)):
311
+ try:
312
+ del headers['Range']
313
+ response = await session.get(clean_url, headers=headers, timeout=8.0)
314
+ if response.status_code == 200 and len(response.content) < 2000000: # Max 2MB
315
+
316
+ if not width or not height:
317
+ try:
318
+ with Image.open(io.BytesIO(response.content)) as img:
319
+ width, height = img.size
320
+ except:
321
+ pass
322
+
323
+ if include_thumbnail and not thumbnail_b64:
324
+ loop = asyncio.get_event_loop()
325
+ thumbnail_b64 = await loop.run_in_executor(
326
+ thumbnail_executor,
327
+ create_thumbnail_cpu_optimized,
328
+ response.content
329
+ )
330
+ except:
331
+ pass
332
+
333
+ except Exception as e:
334
+ pass
335
+
336
+ result = {
337
+ "url": clean_url,
338
+ "width": width,
339
+ "height": height
340
+ }
341
+
342
+ if include_thumbnail:
343
+ result["thumbnail"] = thumbnail_b64
344
+
345
+ # Cache do resultado (limita tamanho do cache)
346
+ if len(_url_cache) < _cache_max_size:
347
+ _url_cache[cache_key] = result.copy()
348
+
349
+ return result
350
+
351
+
352
+ async def enrich_images_ultra_fast(images: List[Dict], include_thumbnails: bool = True) -> List[Dict]:
353
+ """
354
+ Processamento ultra-paralelo com todas as otimizações modernas
355
+ """
356
+ if not images:
357
+ return []
358
+
359
+ # Configuração HTTP2 otimizada para máxima concorrência
360
+ connector = httpx.AsyncClient(
361
+ timeout=httpx.Timeout(10.0),
362
+ limits=httpx.Limits(
363
+ max_keepalive_connections=100, # Muito mais conexões
364
+ max_connections=150, # Pool maior
365
+ keepalive_expiry=30.0 # Mantém conexões por mais tempo
366
+ ),
367
+ http2=False # HTTP/1.1 ainda é mais rápido para muitas conexões pequenas
368
+ )
369
+
370
+ # Semáforo mais agressivo
371
+ semaphore = asyncio.Semaphore(30) # Muito mais concorrência
372
+
373
+ async def process_single_image(image_data):
374
+ async with semaphore:
375
+ return await download_and_process_image(connector, image_data["url"], include_thumbnails)
376
+
377
+ try:
378
+ print(f"Iniciando processamento ultra-paralelo de {len(images)} imagens...")
379
+
380
+ # Cria todas as tasks de uma vez
381
+ tasks = [process_single_image(img) for img in images]
382
+
383
+ # Processa tudo em paralelo com gather otimizado
384
+ results = await asyncio.gather(*tasks, return_exceptions=True)
385
+
386
+ # Filtragem rápida
387
+ valid_results = []
388
+ for result in results:
389
+ if not isinstance(result, Exception) and result.get('width') and result.get('height'):
390
+ valid_results.append(result)
391
+
392
+ success_rate = len(valid_results) / len(images) * 100
393
+ print(f"Processamento concluído: {len(valid_results)}/{len(images)} ({success_rate:.1f}% sucesso)")
394
+
395
+ return valid_results
396
+
397
+ except Exception as e:
398
+ print(f"Erro no processamento ultra-rápido: {e}")
399
+ return []
400
+ finally:
401
+ await connector.aclose()
402
+
403
+
404
+ # Endpoint adicional otimizado
405
+ @router.get("/thumbnail")
406
+ async def get_thumbnail_fast(
407
+ url: str = Query(..., description="URL da imagem para gerar miniatura"),
408
+ size: int = Query(200, description="Tamanho máximo da miniatura em pixels")
409
+ ):
410
+ """
411
+ Obtém miniatura ultra-rápida de uma imagem específica
412
+ """
413
+ try:
414
+ async with httpx.AsyncClient(timeout=8.0) as client:
415
+ result = await download_and_process_image(client, url, True)
416
+
417
+ if result.get('thumbnail'):
418
+ return JSONResponse(content={
419
+ "url": result['url'],
420
+ "thumbnail": result['thumbnail'],
421
+ "dimensions": f"{result.get('width', 0)}x{result.get('height', 0)}",
422
+ "size": size
423
+ })
424
+ else:
425
+ raise HTTPException(status_code=500, detail="Erro ao criar miniatura")
426
+
427
+ except Exception as e:
428
+ raise HTTPException(status_code=500, detail=f"Erro: {str(e)}")
429
+
430
+
431
+ # Cleanup do executor na finalização
432
+ import atexit
433
+ atexit.register(lambda: thumbnail_executor.shutdown(wait=False))
routers/searchterm.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import random
4
+ import asyncio
5
+ import httpx
6
+ import aiohttp
7
+ import trafilatura
8
+ import json
9
+ import uuid
10
+ import time
11
+ from pathlib import Path
12
+ from urllib.parse import urlparse
13
+ from typing import List, Dict, Any, Optional
14
+ from fastapi import APIRouter, HTTPException, Body
15
+ from fastapi.responses import FileResponse
16
+ from newspaper import Article
17
+ from threading import Timer
18
+ from google import genai
19
+ from google.genai import types
20
+
21
+ router = APIRouter()
22
+
23
+ BRAVE_API_KEY = os.getenv("BRAVE_API_KEY")
24
+ if not BRAVE_API_KEY:
25
+ raise ValueError("BRAVE_API_KEY não está definido!")
26
+
27
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
28
+ if not GEMINI_API_KEY:
29
+ raise ValueError("GEMINI_API_KEY não está definido!")
30
+
31
+ BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"
32
+ BRAVE_HEADERS = {
33
+ "Accept": "application/json",
34
+ "Accept-Encoding": "gzip",
35
+ "x-subscription-token": BRAVE_API_KEY
36
+ }
37
+
38
+ USER_AGENTS = [
39
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
40
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
41
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
42
+ ]
43
+
44
+ BLOCKED_DOMAINS = {"reddit.com", "www.reddit.com", "old.reddit.com",
45
+ "quora.com", "www.quora.com"}
46
+
47
+ MAX_TEXT_LENGTH = 4000
48
+
49
+ # Diretório para arquivos temporários
50
+ TEMP_DIR = Path("/tmp")
51
+ TEMP_DIR.mkdir(exist_ok=True)
52
+
53
+ # Dicionário para controlar arquivos temporários
54
+ temp_files = {}
55
+
56
+
57
+ def is_blocked_domain(url: str) -> bool:
58
+ try:
59
+ host = urlparse(url).netloc.lower()
60
+ return any(host == b or host.endswith("." + b) for b in BLOCKED_DOMAINS)
61
+ except Exception:
62
+ return False
63
+
64
+
65
+ def clamp_text(text: str) -> str:
66
+ if not text:
67
+ return ""
68
+ if len(text) > MAX_TEXT_LENGTH:
69
+ return text[:MAX_TEXT_LENGTH]
70
+ return text
71
+
72
+
73
+ def get_realistic_headers() -> Dict[str, str]:
74
+ return {
75
+ "User-Agent": random.choice(USER_AGENTS),
76
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
77
+ "Accept-Language": "en-US,en;q=0.7,pt-BR;q=0.6",
78
+ "Connection": "keep-alive",
79
+ }
80
+
81
+
82
+ def delete_temp_file(file_id: str, file_path: Path):
83
+ """Remove arquivo temporário após expiração"""
84
+ try:
85
+ if file_path.exists():
86
+ file_path.unlink()
87
+ temp_files.pop(file_id, None)
88
+ print(f"Arquivo temporário removido: {file_path}")
89
+ except Exception as e:
90
+ print(f"Erro ao remover arquivo temporário: {e}")
91
+
92
+
93
+ def create_temp_file(data: Dict[str, Any]) -> Dict[str, str]:
94
+ """Cria arquivo temporário e agenda sua remoção"""
95
+ file_id = str(uuid.uuid4())
96
+ file_path = TEMP_DIR / f"fontes_{file_id}.txt"
97
+
98
+ # Salva o JSON no arquivo
99
+ with open(file_path, 'w', encoding='utf-8') as f:
100
+ json.dump(data, f, ensure_ascii=False, indent=2)
101
+
102
+ # Agenda remoção em 24 horas (86400 segundos)
103
+ timer = Timer(86400, delete_temp_file, args=[file_id, file_path])
104
+ timer.start()
105
+
106
+ # Registra o arquivo temporário
107
+ temp_files[file_id] = {
108
+ "path": file_path,
109
+ "created_at": time.time(),
110
+ "timer": timer
111
+ }
112
+
113
+ return {
114
+ "file_id": file_id,
115
+ "download_url": f"/download-temp/{file_id}",
116
+ "expires_in_hours": 24
117
+ }
118
+
119
+
120
+ async def generate_search_terms(context: str) -> List[str]:
121
+ """Gera termos de pesquisa usando o modelo Gemini"""
122
+ try:
123
+ client = genai.Client(api_key=GEMINI_API_KEY)
124
+ model = "gemini-2.5-flash-lite"
125
+
126
+ system_prompt = """Com base num contexto inicial, gere termos de pesquisa (até 20 termos, no máximo), em um JSON. Esses textos devem ser interpretados como termos que podem ser usados por outras inteligências artificiais pra pesquisar no google e retornar resultados mais refinados e completos pra busca atual. Analise muito bem o contexto, por exemplo, se está falando de uma série coreana, gere os termos em coreano por que obviamente na mídia coreana terá mais cobertura que a americana, etc.
127
+
128
+ Deve seguir esse formato: "terms": []
129
+
130
+ Retorne apenas o JSON, sem mais nenhum texto."""
131
+
132
+ contents = [
133
+ types.Content(
134
+ role="user",
135
+ parts=[
136
+ types.Part.from_text(text="Contexto: Taylor Sheridan's 'Landman' Announces Season 2 Premiere Date"),
137
+ ],
138
+ ),
139
+ types.Content(
140
+ role="model",
141
+ parts=[
142
+ types.Part.from_text(text='{"terms": [ "imdb landman episodes season 2", "imdb landman series", "landman season 2 release date", "taylor sheridan landman series", "landman season 2 cast sam elliott", "billy bob thornton returns landman", "demi moore landman new season", "andy garcia ali larter landman season 2", "landman texas oil drama", "taylor sheridan tv series schedule", "landman 10 month turnaround new episodes", "landman season 2 november 16 premiere", "sam elliott joins taylor sheridan show", "landman streaming platform premiere", "landman season 2 filming details", "landman new cast and returning actors", "taylor sheridan quick tv show production" ]}'),
143
+ ],
144
+ ),
145
+ types.Content(
146
+ role="user",
147
+ parts=[
148
+ types.Part.from_text(text="Contexto: Pixar's latest animated feature will arrive on digital (via platforms like Apple TV, Amazon Prime Video, and Fandango at Home) on Aug. 19 and on physical media (4K UHD, Blu-ray and DVD) on Sept. 9. The film has not yet set a Disney+ streaming release date, but that will likely come after the Blu-ray release."),
149
+ ],
150
+ ),
151
+ types.Content(
152
+ role="model",
153
+ parts=[
154
+ types.Part.from_text(text='{ "terms": [ "pixar elio 2024 movie details", "disney pixar new release elio", "elio animated film august 19 digital", "pixar sci-fi comedy elio home release", "elio movie blu-ray dvd release september", "where to watch elio online", "elio streaming disney plus release date", "elio digital release apple tv amazon prime", "elio physical media 4k uhd blu-ray dvd", "elio movie bonus features", "elio cast voice actors", "elio behind the scenes making of", "elio deleted scenes blu-ray", "elio soundtrack and score", "elio merchandise release date", "upcoming disney pixar movies 2024", "pixar elio critical reviews", "elio movie box office results" ] }'),
155
+ ],
156
+ ),
157
+ types.Content(
158
+ role="user",
159
+ parts=[
160
+ types.Part.from_text(text=f"Contexto: {context}"),
161
+ ],
162
+ ),
163
+ ]
164
+
165
+ generate_content_config = types.GenerateContentConfig(
166
+ thinking_config=types.ThinkingConfig(
167
+ thinking_budget=0,
168
+ ),
169
+ )
170
+
171
+ # Coletamos toda a resposta em stream
172
+ full_response = ""
173
+ for chunk in client.models.generate_content_stream(
174
+ model=model,
175
+ contents=contents,
176
+ config=generate_content_config,
177
+ ):
178
+ if chunk.text:
179
+ full_response += chunk.text
180
+
181
+ # Tenta extrair o JSON da resposta
182
+ try:
183
+ # Remove possíveis ```json e ``` da resposta
184
+ clean_response = full_response.strip()
185
+ if clean_response.startswith("```json"):
186
+ clean_response = clean_response[7:]
187
+ if clean_response.endswith("```"):
188
+ clean_response = clean_response[:-3]
189
+ clean_response = clean_response.strip()
190
+
191
+ # Parse do JSON
192
+ response_data = json.loads(clean_response)
193
+ terms = response_data.get("terms", [])
194
+
195
+ # Validação básica
196
+ if not isinstance(terms, list):
197
+ raise ValueError("Terms deve ser uma lista")
198
+
199
+ return terms[:20] # Garante máximo de 20 termos
200
+
201
+ except (json.JSONDecodeError, ValueError) as e:
202
+ print(f"Erro ao parsear resposta do Gemini: {e}")
203
+ print(f"Resposta recebida: {full_response}")
204
+ # Retorna uma lista vazia em caso de erro
205
+ return []
206
+
207
+ except Exception as e:
208
+ print(f"Erro ao gerar termos de pesquisa: {str(e)}")
209
+ return []
210
+
211
+
212
+ async def search_brave_term(client: httpx.AsyncClient, term: str) -> List[Dict[str, str]]:
213
+ params = {"q": term, "count": 10, "safesearch": "off", "summary": "false"}
214
+
215
+ try:
216
+ resp = await client.get(BRAVE_SEARCH_URL, headers=BRAVE_HEADERS, params=params)
217
+ if resp.status_code != 200:
218
+ return []
219
+
220
+ data = resp.json()
221
+ results: List[Dict[str, str]] = []
222
+
223
+ if "web" in data and "results" in data["web"]:
224
+ for item in data["web"]["results"]:
225
+ url = item.get("url")
226
+ age = item.get("age", "Unknown")
227
+
228
+ if url and not is_blocked_domain(url):
229
+ results.append({"url": url, "age": age})
230
+
231
+ return results
232
+ except Exception:
233
+ return []
234
+
235
+
236
+ async def extract_article_text(url: str, session: aiohttp.ClientSession) -> str:
237
+ try:
238
+ art = Article(url)
239
+ art.config.browser_user_agent = random.choice(USER_AGENTS)
240
+ art.config.request_timeout = 8
241
+ art.config.number_threads = 1
242
+
243
+ art.download()
244
+ art.parse()
245
+ txt = (art.text or "").strip()
246
+ if txt and len(txt) > 100:
247
+ return clamp_text(txt)
248
+ except Exception:
249
+ pass
250
+
251
+ try:
252
+ await asyncio.sleep(random.uniform(0.1, 0.3))
253
+
254
+ headers = get_realistic_headers()
255
+ async with session.get(url, headers=headers, timeout=12) as resp:
256
+ if resp.status != 200:
257
+ return ""
258
+
259
+ html = await resp.text()
260
+
261
+ if re.search(r"(paywall|subscribe|metered|registration|captcha|access denied)", html, re.I):
262
+ return ""
263
+
264
+ extracted = trafilatura.extract(html) or ""
265
+ extracted = extracted.strip()
266
+ if extracted and len(extracted) > 100:
267
+ return clamp_text(extracted)
268
+
269
+ except Exception:
270
+ pass
271
+
272
+ return ""
273
+
274
+
275
+ @router.post("/search-terms")
276
+ async def search_terms(payload: Dict[str, str] = Body(...)) -> Dict[str, Any]:
277
+ context = payload.get("context")
278
+ if not context or not isinstance(context, str):
279
+ raise HTTPException(status_code=400, detail="Campo 'context' é obrigatório e deve ser uma string.")
280
+
281
+ if len(context.strip()) == 0:
282
+ raise HTTPException(status_code=400, detail="Campo 'context' não pode estar vazio.")
283
+
284
+ # Gera os termos de pesquisa usando o Gemini
285
+ terms = await generate_search_terms(context)
286
+
287
+ if not terms:
288
+ raise HTTPException(status_code=500, detail="Não foi possível gerar termos de pesquisa válidos.")
289
+
290
+ used_urls = set()
291
+ search_semaphore = asyncio.Semaphore(20)
292
+ extract_semaphore = asyncio.Semaphore(50)
293
+
294
+ async def search_with_limit(client, term):
295
+ async with search_semaphore:
296
+ return await search_brave_term(client, term)
297
+
298
+ async def process_term(session, term, search_results):
299
+ async with extract_semaphore:
300
+ for result in search_results:
301
+ url = result["url"]
302
+ age = result["age"]
303
+
304
+ if url in used_urls:
305
+ continue
306
+
307
+ text = await extract_article_text(url, session)
308
+ if text:
309
+ used_urls.add(url)
310
+ return {
311
+ "term": term,
312
+ "age": age,
313
+ "url": url,
314
+ "text": text
315
+ }
316
+ return None
317
+
318
+ connector = aiohttp.TCPConnector(limit=100, limit_per_host=15)
319
+ timeout = aiohttp.ClientTimeout(total=15)
320
+
321
+ async with httpx.AsyncClient(
322
+ timeout=15.0,
323
+ limits=httpx.Limits(max_connections=100, max_keepalive_connections=25)
324
+ ) as http_client:
325
+ async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
326
+
327
+ search_tasks = [search_with_limit(http_client, term) for term in terms]
328
+ search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
329
+
330
+ process_tasks = []
331
+ for term, results in zip(terms, search_results):
332
+ if isinstance(results, list) and results:
333
+ process_tasks.append(process_term(session, term, results))
334
+
335
+ if process_tasks:
336
+ processed_results = await asyncio.gather(*process_tasks, return_exceptions=True)
337
+ final_results = [r for r in processed_results if r is not None and not isinstance(r, Exception)]
338
+ else:
339
+ final_results = []
340
+
341
+ # Cria o JSON final
342
+ result_data = {"results": final_results}
343
+
344
+ # Cria arquivo temporário
345
+ temp_file_info = create_temp_file(result_data)
346
+
347
+ return {
348
+ "message": "Dados salvos em arquivo temporário",
349
+ "total_results": len(final_results),
350
+ "context": context,
351
+ "generated_terms": terms,
352
+ "file_info": temp_file_info
353
+ }
354
+
355
+
356
+ @router.get("/download-temp/{file_id}")
357
+ async def download_temp_file(file_id: str):
358
+ """Endpoint para download do arquivo temporário"""
359
+ if file_id not in temp_files:
360
+ raise HTTPException(status_code=404, detail="Arquivo não encontrado ou expirado")
361
+
362
+ file_info = temp_files[file_id]
363
+ file_path = file_info["path"]
364
+
365
+ if not file_path.exists():
366
+ temp_files.pop(file_id, None)
367
+ raise HTTPException(status_code=404, detail="Arquivo não encontrado")
368
+
369
+ return FileResponse(
370
+ path=str(file_path),
371
+ filename="fontes.txt",
372
+ media_type="text/plain",
373
+ headers={"Content-Disposition": "attachment; filename=fontes.txt"}
374
+ )
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)
star.png ADDED

Git LFS Details

  • SHA256: 6a1f9dc0773f5a9fb68473b955bef6b6de9feb6b03d394dbb31f8dbde8fb4a50
  • Pointer size: 128 Bytes
  • Size of remote file: 463 Bytes