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")