Spaces:
Paused
Paused
| 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") |