Spaces:
Running
Running
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'<a[^>]*>pic\.x\.com/[^<]*</a>', '', text) | |
text = re.sub(r'<img[^>]*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'<img[^>]*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 = '<img' in raw_text and 'emoji' in raw_text | |
if has_emojis: | |
lines = wrap_text_with_emojis(raw_text, font_text, text_max_width - 100, temp_draw) | |
else: | |
text_lines = wrap_text_with_newlines(cleaned_text, font_text, text_max_width, temp_draw) | |
lines = [{'text': line, 'emojis': []} for line in text_lines] | |
line_height = int(font_text.size * 1.2) | |
text_height = len(lines) * line_height | |
media_height = 0 | |
media_margin = 0 | |
if has_media: | |
if len(cleaned_text) > 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) | |
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") |