newapi-clone / routers /twitter.py
habulaj's picture
Upload 13 files
4ffe0a9 verified
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)
@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")