from fastapi import APIRouter, Query, HTTPException from fastapi.responses import StreamingResponse from PIL import Image, ImageDraw, ImageEnhance, ImageFont from io import BytesIO import requests from typing import Optional, List, Dict import logging from urllib.parse import quote from datetime import datetime import re # Configurar logging logging.basicConfig(level=logging.INFO) log = logging.getLogger("memoriam-api") router = APIRouter() def download_image_from_url(url: str) -> Image.Image: headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36" } response = requests.get(url, headers=headers) if response.status_code != 200: raise HTTPException(status_code=400, detail=f"Imagem não pôde ser baixada. Código {response.status_code}") try: return Image.open(BytesIO(response.content)).convert("RGB") except Exception as e: raise HTTPException(status_code=400, detail=f"Erro ao abrir imagem: {str(e)}") def resize_and_crop_to_fill(img: Image.Image, target_width: int, target_height: int) -> Image.Image: img_ratio = img.width / img.height target_ratio = target_width / target_height if img_ratio > target_ratio: scale_height = target_height scale_width = int(scale_height * img_ratio) else: scale_width = target_width scale_height = int(scale_width / img_ratio) img_resized = img.resize((scale_width, scale_height), Image.LANCZOS) left = (scale_width - target_width) // 2 top = (scale_height - target_height) // 2 right = left + target_width bottom = top + target_height return img_resized.crop((left, top, right, bottom)) def create_bottom_black_gradient(width: int, height: int) -> Image.Image: """Cria um gradiente preto suave que vai do topo transparente até a metade da imagem preto""" gradient = Image.new("RGBA", (width, height), color=(0, 0, 0, 0)) draw = ImageDraw.Draw(gradient) for y in range(height): # Gradiente mais suave que começa transparente e vai até metade da imagem ratio = y / height if ratio <= 0.6: # Primeira parte: totalmente transparente alpha = 0 elif ratio <= 0.75: # Transição muito suave (60% a 75% da altura) alpha = int(80 * (ratio - 0.6) / 0.15) else: # Final suave (75% a 100% da altura) alpha = int(80 + 50 * (ratio - 0.75) / 0.25) # Usar preto puro (0, 0, 0) com alpha mais baixo draw.line([(0, y), (width, y)], fill=(0, 0, 0, alpha)) return gradient def create_top_black_gradient(width: int, height: int) -> Image.Image: """Cria um gradiente preto suave que vai do fundo transparente até a metade da imagem preto""" gradient = Image.new("RGBA", (width, height), color=(0, 0, 0, 0)) draw = ImageDraw.Draw(gradient) for y in range(height): # Gradiente mais suave que começa preto e vai até metade da imagem ratio = y / height if ratio <= 0.25: # Primeira parte suave (0% a 25% da altura) alpha = int(80 + 50 * (0.25 - ratio) / 0.25) elif ratio <= 0.4: # Transição muito suave (25% a 40% da altura) alpha = int(80 * (0.4 - ratio) / 0.15) else: # Segunda parte: totalmente transparente alpha = 0 # Usar preto puro (0, 0, 0) com alpha mais baixo draw.line([(0, y), (width, y)], fill=(0, 0, 0, alpha)) return gradient def draw_text_left_aligned(draw: ImageDraw.Draw, text: str, x: int, y: int, font_path: str, font_size: int): """Desenha texto alinhado à esquerda com especificações exatas""" try: font = ImageFont.truetype(font_path, font_size) except Exception: font = ImageFont.load_default() # Espaçamento entre letras 0% e cor branca draw.text((x, y), text, font=font, fill=(255, 255, 255), spacing=0) def clean_name_parentheses(name: str) -> str: """ Remove parênteses e seu conteúdo do nome e remove espaços extras Exemplo: "Charlie Kirk (Activist)" -> "Charlie Kirk" """ if not name: return name # Remove parênteses e todo o conteúdo dentro deles cleaned_name = re.sub(r'\s*\([^)]*\)', '', name) # Remove espaços extras no início, fim e múltiplos espaços no meio cleaned_name = re.sub(r'\s+', ' ', cleaned_name.strip()) return cleaned_name def search_wikipedia(name: str) -> List[Dict]: """ Busca nomes na Wikipedia e retorna lista com foto, nome completo e wikibase_item """ try: # Primeira busca para obter dados básicos e foto search_url = "https://en.wikipedia.org/w/rest.php/v1/search/title" search_params = { "q": name, "limit": 5 # Limite de resultados } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36" } response = requests.get(search_url, params=search_params, headers=headers) response.raise_for_status() search_data = response.json() results = [] for page in search_data.get("pages", []): title = page.get("title", "") description = page.get("description", "") thumbnail = page.get("thumbnail", {}) # Obter wikibase_item usando a API de props wikibase_item = None try: props_url = "https://en.wikipedia.org/w/api.php" props_params = { "action": "query", "prop": "pageprops", "titles": title, "format": "json" } props_response = requests.get(props_url, params=props_params, headers=headers) props_response.raise_for_status() props_data = props_response.json() pages = props_data.get("query", {}).get("pages", {}) for page_id, page_data in pages.items(): pageprops = page_data.get("pageprops", {}) wikibase_item = pageprops.get("wikibase_item") break except Exception as e: log.warning(f"Erro ao obter wikibase_item para {title}: {e}") # Construir URL completa da imagem image_url = None if thumbnail and thumbnail.get("url"): thumb_url = thumbnail["url"] # A URL vem como //upload.wikimedia... então precisa adicionar https: if thumb_url.startswith("//"): image_url = f"https:{thumb_url}" # Converter para versão de tamanho maior (remover o /60px- e usar tamanho original) image_url = image_url.replace("/60px-", "/400px-") else: image_url = thumb_url result = { "name": title, "description": description, "image_url": image_url, "wikibase_item": wikibase_item } results.append(result) return results except Exception as e: log.error(f"Erro na busca Wikipedia: {e}") raise HTTPException(status_code=500, detail=f"Erro ao buscar na Wikipedia: {str(e)}") def get_wikidata_dates(wikibase_item: str) -> Dict[str, Optional[str]]: """ Consulta o Wikidata para obter datas de nascimento (P569) e falecimento (P570) """ try: if not wikibase_item or not wikibase_item.startswith('Q'): return {"birth": None, "death": None} url = f"https://www.wikidata.org/wiki/Special:EntityData/{wikibase_item}.json" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36" } response = requests.get(url, headers=headers) response.raise_for_status() data = response.json() # Navegar até as claims entities = data.get("entities", {}) entity_data = entities.get(wikibase_item, {}) claims = entity_data.get("claims", {}) birth_date = None death_date = None # Extrair data de nascimento (P569) if "P569" in claims: birth_claims = claims["P569"] for claim in birth_claims: try: mainsnak = claim.get("mainsnak", {}) datavalue = mainsnak.get("datavalue", {}) value = datavalue.get("value", {}) time = value.get("time") if time: # Formato: +1943-08-17T00:00:00Z year = time.split("-")[0].replace("+", "") birth_date = year break except Exception as e: log.warning(f"Erro ao processar data de nascimento: {e}") continue # Extrair data de falecimento (P570) if "P570" in claims: death_claims = claims["P570"] for claim in death_claims: try: mainsnak = claim.get("mainsnak", {}) datavalue = mainsnak.get("datavalue", {}) value = datavalue.get("value", {}) time = value.get("time") if time: # Formato: +2023-01-15T00:00:00Z year = time.split("-")[0].replace("+", "") death_date = year break except Exception as e: log.warning(f"Erro ao processar data de falecimento: {e}") continue return {"birth": birth_date, "death": death_date} except Exception as e: log.error(f"Erro ao consultar Wikidata para {wikibase_item}: {e}") return {"birth": None, "death": None} def create_canvas(image_url: Optional[str], name: Optional[str], birth: Optional[str], death: Optional[str], text_position: str = "bottom") -> BytesIO: # Dimensões fixas para Instagram width = 1080 height = 1350 canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0)) # Fundo transparente # Carregar e processar imagem de fundo se fornecida if image_url: try: img = download_image_from_url(image_url) img_bw = ImageEnhance.Color(img).enhance(0.0).convert("RGBA") filled_img = resize_and_crop_to_fill(img_bw, width, height) canvas.paste(filled_img, (0, 0)) except Exception as e: log.warning(f"Erro ao carregar imagem: {e}") # Aplicar gradiente baseado na posição do texto if text_position.lower() == "top": gradient_overlay = create_top_black_gradient(width, height) else: # bottom gradient_overlay = create_bottom_black_gradient(width, height) canvas = Image.alpha_composite(canvas, gradient_overlay) # Adicionar logo no canto inferior direito com opacidade try: logo = Image.open("recurve.png").convert("RGBA") logo_resized = logo.resize((120, 22)) # Aplicar opacidade à logo logo_with_opacity = Image.new("RGBA", logo_resized.size) logo_with_opacity.paste(logo_resized, (0, 0)) # Reduzir opacidade logo_alpha = logo_with_opacity.split()[-1].point(lambda x: int(x * 0.42)) # 42% de opacidade logo_with_opacity.putalpha(logo_alpha) logo_padding = 40 logo_x = width - 120 - logo_padding logo_y = height - 22 - logo_padding canvas.paste(logo_with_opacity, (logo_x, logo_y), logo_with_opacity) except Exception as e: log.warning(f"Erro ao carregar a logo: {e}") draw = ImageDraw.Draw(canvas) # Configurar posições baseadas no text_position text_x = 80 # Alinhamento à esquerda com margem if text_position.lower() == "top": dates_y = 100 name_y = dates_y + 36 + 6 # Ano + espaçamento de 6px + nome else: # bottom dates_y = height - 250 name_y = dates_y + 36 + 6 # Ano + espaçamento de 6px + nome # Desenhar datas primeiro (se fornecidas) if birth or death: font_path_regular = "fonts/AGaramondPro-Regular.ttf" # Construir texto das datas dates_text = "" if birth and death: dates_text = f"{birth} - {death}" elif birth: dates_text = f"{birth}" elif death: dates_text = f"- {death}" if dates_text: draw_text_left_aligned(draw, dates_text, text_x, dates_y, font_path_regular, 36) # Desenhar nome abaixo das datas if name: font_path = "fonts/AGaramondPro-BoldItalic.ttf" draw_text_left_aligned(draw, name, text_x, name_y, font_path, 87) buffer = BytesIO() canvas.save(buffer, format="PNG") buffer.seek(0) return buffer @router.get("/search/wikipedia") def search_wikipedia_names( name: str = Query(..., description="Nome para buscar na Wikipedia") ): """ Busca nomes na Wikipedia e retorna lista com foto, nome completo e wikibase_item. Retorna até 5 resultados ordenados por relevância. """ if not name or len(name.strip()) < 2: raise HTTPException(status_code=400, detail="Nome deve ter pelo menos 2 caracteres") try: results = search_wikipedia(name.strip()) return { "query": name, "results": results, "total": len(results) } except Exception as e: log.error(f"Erro na busca: {e}") raise HTTPException(status_code=500, detail=f"Erro ao buscar: {str(e)}") @router.get("/wikidata/dates") def get_wikidata_person_info( wikibase_item: str = Query(..., description="ID do Wikidata (ex: Q10304982)"), name: Optional[str] = Query(None, description="Nome da pessoa (opcional)"), image_url: Optional[str] = Query(None, description="URL da imagem (opcional, padrão: placeholder)") ): """ Consulta o Wikidata para obter datas de nascimento e falecimento, e retorna URL formatada para o endpoint de memoriam. Se death_year for null/vazio, usa o ano atual por padrão. Remove automaticamente parênteses do nome para manter a URL limpa. """ if not wikibase_item or not wikibase_item.startswith('Q'): raise HTTPException(status_code=400, detail="wikibase_item deve ser um ID válido do Wikidata (ex: Q10304982)") try: # Obter datas do Wikidata dates = get_wikidata_dates(wikibase_item) birth_year = dates.get("birth") death_year = dates.get("death") # Se death_year estiver vazio/null, usar o ano atual if not death_year: death_year = str(datetime.now().year) # Usar placeholder como padrão se image_url não fornecida if not image_url: image_url = "https://placehold.co/1080x1350.png" # Limpar nome removendo parênteses clean_name = clean_name_parentheses(name) if name else None # Construir URL do memoriam base_url = "https://habulaj-recurve-api-img.hf.space/cover/memoriam" params = [] if clean_name: params.append(f"name={quote(clean_name)}") if birth_year: params.append(f"birth={birth_year}") if death_year: params.append(f"death={death_year}") if image_url: params.append(f"image_url={quote(image_url)}") # Sempre adicionar text_position=bottom como padrão params.append("text_position=bottom") # Montar URL final memoriam_url = base_url + "?" + "&".join(params) return { "wikibase_item": wikibase_item, "name": name, # Nome original fornecido "clean_name": clean_name, # Nome limpo sem parênteses "image_url": image_url, "birth_year": birth_year, "death_year": death_year, "memoriam_url": memoriam_url, "dates_found": { "birth": birth_year is not None, "death": dates.get("death") is not None # Original death from Wikidata }, "death_year_source": "wikidata" if dates.get("death") else "current_year" } except Exception as e: log.error(f"Erro ao processar informações: {e}") raise HTTPException(status_code=500, detail=f"Erro ao processar: {str(e)}") @router.get("/cover/memoriam") def get_memoriam_image( image_url: Optional[str] = Query(None, description="URL da imagem de fundo"), name: Optional[str] = Query(None, description="Nome (será exibido em maiúsculas)"), birth: Optional[str] = Query(None, description="Ano de nascimento (ex: 1943)"), death: Optional[str] = Query(None, description="Ano de falecimento (ex: 2023)"), text_position: str = Query("bottom", description="Posição do texto: 'top' ou 'bottom'") ): """ Gera imagem de memoriam no formato 1080x1350 (Instagram). Todos os parâmetros são opcionais, mas recomenda-se fornecer pelo menos o nome. O gradiente será aplicado baseado na posição do texto (top ou bottom). """ try: buffer = create_canvas(image_url, name, birth, death, text_position) return StreamingResponse(buffer, media_type="image/png") except Exception as e: raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}")