from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import StreamingResponse
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
import requests
import re
from html import unescape
router = APIRouter()
def fetch_tweet_data(tweet_id: str) -> dict:
url = f"https://tweethunter.io/api/thread?tweetId={tweet_id}"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0",
"Accept": "application/json",
"Referer": "https://tweethunter.io/tweetpik"
}
try:
resp = requests.get(url, headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
if not data:
raise HTTPException(status_code=404, detail="Tweet não encontrado")
return data[0]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Erro ao buscar tweet: {e}")
def download_emoji(emoji_url: str) -> Image.Image:
try:
response = requests.get(emoji_url, timeout=10)
response.raise_for_status()
emoji_img = Image.open(BytesIO(response.content)).convert("RGBA")
return emoji_img.resize((32, 32), Image.Resampling.LANCZOS)
except Exception as e:
print(f"Erro ao baixar emoji {emoji_url}: {e}")
return None
def clean_tweet_text(text: str) -> str:
if not text:
return ""
text = re.sub(r']*>pic\.x\.com/[^<]*', '', text)
text = re.sub(r'
]*alt="([^"]*)"[^>]*/?>', r'\1', text)
text = re.sub(r'<[^>]+>', '', text)
text = unescape(text)
text = text.replace('\\n', '\n')
text = re.sub(r'\n\s*\n', '\n\n', text)
text = text.strip()
return text
def extract_emojis_from_html(text: str) -> list:
emoji_pattern = r'
]*class="emoji"[^>]*alt="([^"]*)"[^>]*src="([^"]*)"[^>]*/?>'
emojis = []
for match in re.finditer(emoji_pattern, text):
emoji_char = match.group(1)
emoji_url = match.group(2)
start_pos = match.start()
end_pos = match.end()
emojis.append({
'char': emoji_char,
'url': emoji_url,
'start': start_pos,
'end': end_pos
})
return emojis
def wrap_text_with_emojis(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> list:
emojis = extract_emojis_from_html(text)
clean_text = clean_tweet_text(text)
paragraphs = clean_text.split('\n')
all_lines = []
emoji_positions = []
current_char_index = 0
for paragraph in paragraphs:
if not paragraph.strip():
all_lines.append({
'text': "",
'emojis': []
})
current_char_index += 1
continue
words = paragraph.split()
current_line = ""
line_emojis = []
for word in words:
test_line = f"{current_line} {word}".strip()
emoji_count_in_word = 0
for emoji in emojis:
if emoji['char'] in word:
emoji_count_in_word += len(emoji['char'])
text_width = draw.textlength(test_line, font=font)
emoji_width = emoji_count_in_word * 32
total_width = text_width + emoji_width
if total_width <= max_width:
current_line = test_line
for emoji in emojis:
if emoji['char'] in word:
emoji_pos_in_line = len(current_line) - len(word) + word.find(emoji['char'])
line_emojis.append({
'emoji': emoji,
'position': emoji_pos_in_line
})
else:
if current_line:
all_lines.append({
'text': current_line,
'emojis': line_emojis.copy()
})
current_line = word
line_emojis = []
for emoji in emojis:
if emoji['char'] in word:
emoji_pos_in_line = word.find(emoji['char'])
line_emojis.append({
'emoji': emoji,
'position': emoji_pos_in_line
})
if current_line:
all_lines.append({
'text': current_line,
'emojis': line_emojis.copy()
})
current_char_index += len(paragraph) + 1
return all_lines
def wrap_text_with_newlines(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> list[str]:
paragraphs = text.split('\n')
all_lines = []
for paragraph in paragraphs:
if not paragraph.strip():
all_lines.append("")
continue
words = paragraph.split()
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip()
if draw.textlength(test_line, font=font) <= max_width:
current_line = test_line
else:
if current_line:
all_lines.append(current_line)
current_line = word
if current_line:
all_lines.append(current_line)
return all_lines
def download_and_resize_image(url: str, max_width: int, max_height: int) -> Image.Image:
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
img = Image.open(BytesIO(response.content)).convert("RGB")
original_width, original_height = img.size
ratio = min(max_width / original_width, max_height / original_height)
new_width = int(original_width * ratio)
new_height = int(original_height * ratio)
return img.resize((new_width, new_height), Image.Resampling.LANCZOS)
except Exception as e:
print(f"Erro ao baixar imagem {url}: {e}")
return None
def create_verification_badge(draw: ImageDraw.Draw, x: int, y: int, size: int = 24):
blue_color = (27, 149, 224)
draw.ellipse((x, y, x + size, y + size), fill=blue_color)
check_points = [
(x + size * 0.25, y + size * 0.5),
(x + size * 0.45, y + size * 0.7),
(x + size * 0.75, y + size * 0.3)
]
line_width = max(2, size // 12)
for i in range(len(check_points) - 1):
draw.line([check_points[i], check_points[i + 1]], fill=(255, 255, 255), width=line_width)
def format_number(num: int) -> str:
if num >= 1000000:
return f"{num / 1000000:.1f}M"
elif num >= 1000:
return f"{num / 1000:.1f}K"
else:
return str(num)
def draw_rounded_rectangle(draw: ImageDraw.Draw, bbox: tuple, radius: int, fill: tuple):
x1, y1, x2, y2 = bbox
draw.rectangle((x1 + radius, y1, x2 - radius, y2), fill=fill)
draw.rectangle((x1, y1 + radius, x2, y2 - radius), fill=fill)
draw.pieslice((x1, y1, x1 + 2*radius, y1 + 2*radius), 180, 270, fill=fill)
draw.pieslice((x2 - 2*radius, y1, x2, y1 + 2*radius), 270, 360, fill=fill)
draw.pieslice((x1, y2 - 2*radius, x1 + 2*radius, y2), 90, 180, fill=fill)
draw.pieslice((x2 - 2*radius, y2 - 2*radius, x2, y2), 0, 90, fill=fill)
def draw_rounded_image(img: Image.Image, photo_img: Image.Image, x: int, y: int, radius: int = 16):
mask = Image.new("L", photo_img.size, 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.rounded_rectangle((0, 0, photo_img.width, photo_img.height), radius, fill=255)
rounded_img = Image.new("RGBA", photo_img.size, (0, 0, 0, 0))
rounded_img.paste(photo_img, (0, 0))
rounded_img.putalpha(mask)
img.paste(rounded_img, (x, y), rounded_img)
def create_tweet_image(tweet: dict) -> BytesIO:
WIDTH, HEIGHT = 1080, 1350
OUTER_BG_COLOR = (0, 0, 0)
INNER_BG_COLOR = (255, 255, 255)
TEXT_COLOR = (2, 6, 23)
SECONDARY_COLOR = (100, 116, 139)
STATS_COLOR = (110, 118, 125)
OUTER_PADDING = 64
INNER_PADDING = 48
BORDER_RADIUS = 32
AVATAR_SIZE = 96
raw_text = tweet.get("textHtml", "")
cleaned_text = clean_tweet_text(raw_text)
photos = tweet.get("photos", [])
videos = tweet.get("videos", [])
media_url = None
if videos and videos[0].get("poster"):
media_url = videos[0]["poster"]
elif photos:
media_url = photos[0]
has_media = media_url is not None
base_font_size = 40
max_iterations = 10
current_iteration = 0
while current_iteration < max_iterations:
try:
font_name = ImageFont.truetype("fonts/Chirp Bold.woff", int(base_font_size * 0.9))
font_handle = ImageFont.truetype("fonts/Chirp Regular.woff", int(base_font_size * 0.9))
font_text = ImageFont.truetype("fonts/Chirp Regular.woff", base_font_size)
font_stats_number = ImageFont.truetype("fonts/Chirp Bold.woff", int(base_font_size * 0.9))
font_stats_label = ImageFont.truetype("fonts/Chirp Regular.woff", int(base_font_size * 0.9))
except:
font_name = ImageFont.load_default()
font_handle = ImageFont.load_default()
font_text = ImageFont.load_default()
font_stats_number = ImageFont.load_default()
font_stats_label = ImageFont.load_default()
text_max_width = WIDTH - (2 * OUTER_PADDING) - (2 * INNER_PADDING)
temp_img = Image.new("RGB", (100, 100))
temp_draw = ImageDraw.Draw(temp_img)
has_emojis = '
200:
media_height = 250
elif len(cleaned_text) > 100:
media_height = 350
else:
media_height = 450
media_margin = 24
header_height = AVATAR_SIZE + 16
text_margin = 20
stats_height = 40
stats_margin = 32
total_content_height = (
INNER_PADDING +
header_height +
text_margin +
text_height +
(media_margin if has_media else 0) +
media_height +
(media_margin if has_media else 0) +
stats_margin +
stats_height +
INNER_PADDING
)
max_card_height = HEIGHT - (2 * OUTER_PADDING)
if total_content_height <= max_card_height or base_font_size <= 24:
break
base_font_size -= 2
current_iteration += 1
card_height = min(total_content_height, HEIGHT - (2 * OUTER_PADDING))
card_width = WIDTH - (2 * OUTER_PADDING)
card_x = OUTER_PADDING
card_y = (HEIGHT - card_height) // 2 - 30
img = Image.new("RGB", (WIDTH, HEIGHT), OUTER_BG_COLOR)
draw = ImageDraw.Draw(img)
draw_rounded_rectangle(
draw,
(card_x, card_y, card_x + card_width, card_y + card_height),
BORDER_RADIUS,
INNER_BG_COLOR
)
content_x = card_x + INNER_PADDING
current_y = card_y + INNER_PADDING
avatar_y = current_y
try:
avatar_resp = requests.get(tweet["avatarUrl"], timeout=10)
avatar_img = Image.open(BytesIO(avatar_resp.content)).convert("RGBA")
avatar_img = avatar_img.resize((AVATAR_SIZE, AVATAR_SIZE), Image.Resampling.LANCZOS)
mask = Image.new("L", (AVATAR_SIZE, AVATAR_SIZE), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse((0, 0, AVATAR_SIZE, AVATAR_SIZE), fill=255)
img.paste(avatar_img, (content_x, avatar_y), mask)
except:
draw.ellipse(
(content_x, avatar_y, content_x + AVATAR_SIZE, avatar_y + AVATAR_SIZE),
fill=(200, 200, 200)
)
user_info_x = content_x + AVATAR_SIZE + 20
user_info_y = avatar_y
name = tweet.get("nameHtml", "Nome Desconhecido")
name = clean_tweet_text(name)
draw.text((user_info_x, user_info_y), name, font=font_name, fill=TEXT_COLOR)
verified = tweet.get("verified", False)
if verified:
name_width = draw.textlength(name, font=font_name)
badge_x = user_info_x + name_width + 14
badge_y = user_info_y + 6
create_verification_badge(draw, badge_x, badge_y, 28)
handle = tweet.get("handler", "@unknown")
if not handle.startswith('@'):
handle = f"@{handle}"
handle_y = user_info_y + 44
draw.text((user_info_x, handle_y), handle, font=font_handle, fill=SECONDARY_COLOR)
current_y = avatar_y + header_height + text_margin
for line_data in lines:
line_text = line_data['text']
line_emojis = line_data.get('emojis', [])
if line_text.strip() or line_emojis:
text_x = content_x
if has_emojis and line_emojis:
current_x = text_x
text_parts = []
last_pos = 0
sorted_emojis = sorted(line_emojis, key=lambda e: e['position'])
for emoji_data in sorted_emojis:
emoji_pos = emoji_data['position']
emoji_info = emoji_data['emoji']
if emoji_pos > last_pos:
text_before = line_text[last_pos:emoji_pos]
if text_before:
draw.text((current_x, current_y), text_before, font=font_text, fill=TEXT_COLOR)
current_x += draw.textlength(text_before, font=font_text)
emoji_img = download_emoji(emoji_info['url'])
if emoji_img:
emoji_y = current_y + (line_height - 32) // 2
img.paste(emoji_img, (int(current_x), int(emoji_y)), emoji_img)
current_x += 32
else:
draw.text((current_x, current_y), emoji_info['char'], font=font_text, fill=TEXT_COLOR)
current_x += draw.textlength(emoji_info['char'], font=font_text)
last_pos = emoji_pos + len(emoji_info['char'])
if last_pos < len(line_text):
remaining_text = line_text[last_pos:]
draw.text((current_x, current_y), remaining_text, font=font_text, fill=TEXT_COLOR)
else:
draw.text((text_x, current_y), line_text, font=font_text, fill=TEXT_COLOR)
current_y += line_height
if has_media:
current_y += media_margin
media_img = download_and_resize_image(media_url, text_max_width, media_height)
if media_img:
media_x = content_x
media_y = current_y
draw_rounded_image(img, media_img, media_x, media_y, 16)
current_y = media_y + media_img.height + media_margin
current_y += stats_margin
stats_y = current_y
stats_x = content_x
retweets = tweet.get("retweets", 0)
retweets_text = format_number(retweets)
draw.text((stats_x, stats_y), retweets_text, font=font_stats_number, fill=TEXT_COLOR)
retweets_num_width = draw.textlength(retweets_text, font=font_stats_number)
retweets_label_x = stats_x + retweets_num_width + 12
draw.text((retweets_label_x, stats_y), "Retweets", font=font_stats_label, fill=STATS_COLOR)
retweets_label_width = draw.textlength("Retweets", font=font_stats_label)
likes_x = retweets_label_x + retweets_label_width + 44
likes = tweet.get("likes", 0)
likes_text = format_number(likes)
draw.text((likes_x, stats_y), likes_text, font=font_stats_number, fill=TEXT_COLOR)
likes_num_width = draw.textlength(likes_text, font=font_stats_number)
likes_label_x = likes_x + likes_num_width + 12
draw.text((likes_label_x, stats_y), "Likes", font=font_stats_label, fill=STATS_COLOR)
try:
logo_path = "recurve.png"
logo = Image.open(logo_path).convert("RGBA")
logo_width, logo_height = 121, 23
logo_resized = logo.resize((logo_width, logo_height))
logo_with_opacity = Image.new("RGBA", logo_resized.size)
for x in range(logo_resized.width):
for y in range(logo_resized.height):
r, g, b, a = logo_resized.getpixel((x, y))
new_alpha = int(a * 0.42)
logo_with_opacity.putpixel((x, y), (r, g, b, new_alpha))
logo_x = WIDTH - logo_width - 64
logo_y = HEIGHT - logo_height - 64
img.paste(logo_with_opacity, (logo_x, logo_y), logo_with_opacity)
except Exception as e:
print(f"Erro ao carregar a logo: {e}")
buffer = BytesIO()
img.save(buffer, format="PNG", quality=95)
buffer.seek(0)
return buffer
def extract_tweet_id(tweet_url: str) -> str:
match = re.search(r"/status/(\d+)", tweet_url)
if not match:
raise HTTPException(status_code=400, detail="URL de tweet inválida")
return match.group(1)
@router.get("/tweet/image")
def get_tweet_image(tweet_url: str = Query(..., description="URL do tweet")):
tweet_id = extract_tweet_id(tweet_url)
tweet_data = fetch_tweet_data(tweet_id)
img_buffer = create_tweet_image(tweet_data)
return StreamingResponse(img_buffer, media_type="image/png")