Spaces:
Paused
Paused
Squaad AI
commited on
Commit
·
ac37309
1
Parent(s):
8a40ae6
Initial commit from cloned Space
Browse files- .gitattributes +23 -0
- Dockerfile +15 -0
- README.md +4 -4
- app.py +50 -0
- arrastar.png +3 -0
- assets/haarcascade_frontalface_default.xml +0 -0
- cross.png +3 -0
- fonts/AGaramondPro-Bold.ttf +3 -0
- fonts/AGaramondPro-BoldItalic.ttf +3 -0
- fonts/AGaramondPro-Italic.ttf +3 -0
- fonts/AGaramondPro-Regular.ttf +3 -0
- fonts/AGaramondPro-Semibold.ttf +3 -0
- fonts/AGaramondPro-SemiboldItalic.ttf +3 -0
- fonts/Chirp Bold.woff +0 -0
- fonts/Chirp Regular.woff +0 -0
- fonts/Inter-Bold.otf +3 -0
- fonts/Montserrat-Bold.ttf +3 -0
- fonts/Montserrat-Medium.ttf +3 -0
- fonts/Montserrat-SemiBold.ttf +3 -0
- fonts/WorkSans-Italic.ttf +3 -0
- fonts/WorkSans-Regular.ttf +3 -0
- fonts/WorkSans-SemiBold.ttf +3 -0
- fonts/WorkSans-SemiBoldItalic.ttf +3 -0
- recurve.png +3 -0
- recurvecuriosity.png +3 -0
- requirements.txt +35 -0
- routers/__init__.py +1 -0
- routers/analyze.py +501 -0
- routers/curiosity.py +403 -0
- routers/db.py +60 -0
- routers/filter.py +1011 -0
- routers/getnews.py +134 -0
- routers/image.py +701 -0
- routers/inference.py +593 -0
- routers/inference_createposter.py +692 -0
- routers/memoriam.py +438 -0
- routers/news.py +479 -0
- routers/search.py +433 -0
- routers/searchterm.py +374 -0
- routers/subtitle.py +497 -0
- routers/twitter.py +502 -0
- routers/video.py +396 -0
- star.png +3 -0
.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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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
|
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
|
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
|
recurvecuriosity.png
ADDED
|
Git LFS Details
|
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('<', '<').replace('>', '>').replace('&', '&')
|
| 140 |
+
text = text.replace('"', '"').replace(''', "'")
|
| 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
|