"""Análisis de sentimiento de comentarios de YouTube. Motor ligero y autónomo: usa lexicones español/inglés y heurísticas para clasificar comentarios en positivo / neutro / negativo sin depender de Gemini ni de modelos pesados. Además genera insumos visuales para la app: - porcentajes por sentimiento, - gráfico de distribución, - nubes de palabras positivas, neutras y negativas sin stopwords, - lectura narrativa para recolectar insights del público. Para obtener comentarios reales se necesita YouTube Data API v3 (opcional). Si no hay key, acepta comentarios pegados manualmente. """ from __future__ import annotations import re from collections import Counter from pathlib import Path from typing import Any, Dict, List, Optional, Tuple # --------------------------------------------------------------------------- # Lexicones simples (ampliables sin tocar la lógica) # --------------------------------------------------------------------------- POSITIVE_TERMS = { "excelente", "genial", "increíble", "increible", "gracias", "perfecto", "buenísimo", "buenisimo", "fenomenal", "me encanta", "me encantó", "me encanto", "fascinante", "útil", "util", "aprendo", "aprendí", "aprendi", "recomiendo", "lo mejor", "top", "great", "amazing", "excellent", "love", "helpful", "thanks", "awesome", "fantastic", "brilliant", "perfect", "wonderful", "like", "best", "bravo", "super", "espectacular", "clarísimo", "clarisimo", "claro", "entendí", "entendi", "buen contenido", "sigue así", "sigue asi", "suscrito", "me suscribí", "me suscribi", "vale la pena", "funciona", "me sirvió", "me sirvio", "aporta", } NEGATIVE_TERMS = { "malo", "pésimo", "pesimo", "horrible", "terrible", "no sirve", "no funciona", "no entiendo", "confuso", "aburrido", "click bait", "clickbait", "mentira", "estafa", "engaño", "engano", "basura", "desperdicio", "pérdida de tiempo", "perdida de tiempo", "bad", "terrible", "horrible", "useless", "boring", "wrong", "misleading", "fake", "scam", "waste", "dislike", "nope", "awful", "poor", "no me gustó", "no me gusto", "decepcionante", "decepciona", "deficiente", "caro", "lento", "falló", "fallo", "problema", "fraude", "molesto", "queja", } INTENSIFIERS = { "muy", "super", "demasiado", "extremadamente", "completamente", "totalmente", "absolutamente", "increíblemente", "increiblemente", "realmente", "bastante", "quite", "very", "so", "extremely", "absolutely", } NEGATORS = {"no", "ni", "nunca", "jamás", "jamas", "tampoco", "sin", "not", "never", "barely"} STOPWORDS_ES = { "de", "la", "el", "y", "en", "a", "que", "del", "los", "las", "un", "una", "por", "para", "con", "su", "al", "se", "es", "son", "como", "más", "mas", "pero", "sin", "ya", "muy", "sobre", "entre", "hay", "ser", "fue", "han", "ha", "lo", "le", "les", "me", "te", "nos", "mi", "tu", "sus", "ese", "esa", "eso", "esto", "esta", "estos", "estas", "desde", "hasta", "donde", "cuando", "también", "tambien", "porque", "https", "http", "com", "www", "rt", "tco", "amp", "video", "imagen", "marca", "contenido", "publicación", "publicacion", "post", "reel", "comentario", "comentarios", "youtube", "canal", "ver", "veo", "hacer", "hace", "haces", "solo", "asi", "así", "ahi", "ahí", "este", "esta", "estos", "estas", "uno", "dos", "tres", "parte", "tema", } # --------------------------------------------------------------------------- # Limpieza y scoring # --------------------------------------------------------------------------- def _clean(text: str) -> str: text = (text or "").lower().strip() text = re.sub(r"[^\w\sáéíóúüñ]", " ", text, flags=re.UNICODE) return re.sub(r"\s+", " ", text).strip() def _tokens_sin_stopwords(text: str, stopwords_extra: Optional[List[str]] = None) -> List[str]: stopwords = set(STOPWORDS_ES) if stopwords_extra: stopwords.update(str(w).lower().strip() for w in stopwords_extra if str(w).strip()) clean = (text or "").lower() clean = re.sub(r"https?://\S+", " ", clean) clean = re.sub(r"www\.\S+", " ", clean) clean = re.sub(r"[@#]\w+", " ", clean) clean = re.sub(r"[^a-záéíóúüñ\s]", " ", clean, flags=re.UNICODE) clean = re.sub(r"\s+", " ", clean).strip() return [token for token in clean.split() if len(token) >= 3 and token not in stopwords] def _top_words(comments: List[str], n: int = 20) -> List[Tuple[str, int]]: counter: Counter[str] = Counter() for comment in comments: counter.update(_tokens_sin_stopwords(comment)) return counter.most_common(n) def _score_comment(text: str) -> float: """Devuelve un score de -1 (muy neg) a +1 (muy pos). 0 = neutro.""" clean = _clean(text) words = clean.split() if not words: return 0.0 pos = sum(1 for t in POSITIVE_TERMS if t in clean) neg = sum(1 for t in NEGATIVE_TERMS if t in clean) # Detectar negaciones que invierten positivos (ej: "no me encanta") for i, w in enumerate(words): if w in NEGATORS: context = " ".join(words[i + 1: i + 5]) if any(t in context for t in POSITIVE_TERMS): neg += 1 pos = max(0, pos - 1) # Intensificadores amplían la magnitud intensity = 1.0 + 0.3 * sum(1 for w in words if w in INTENSIFIERS) raw = (pos - neg) * intensity return max(-1.0, min(1.0, raw / max(pos + neg, 1))) def classify_comment(text: str) -> str: score = _score_comment(text) if score > 0.2: return "positivo" if score < -0.2: return "negativo" return "neutro" # --------------------------------------------------------------------------- # API pública # --------------------------------------------------------------------------- def analyze_comments_sentiment( comments: List[str], max_comments: int = 100, ) -> Dict[str, Any]: """Analiza el sentimiento de una lista de comentarios. Returns dict con porcentajes, ejemplos, palabras frecuentes y resultados por comentario. La app solo muestra markdown + gráficos; este JSON queda para API/debug. """ empty = { "total": 0, "positivos": 0, "neutros": 0, "negativos": 0, "pct_positivo": 0.0, "pct_neutro": 0.0, "pct_negativo": 0.0, "score_promedio": 0.0, "sentimiento_dominante": "neutro", "ejemplos": {"positivos": [], "neutros": [], "negativos": []}, "palabras_frecuentes": {"positivas": [], "neutras": [], "negativas": []}, "resultados": [], "warning": "Sin comentarios para analizar.", } if not comments: return empty filtered = [c.strip() for c in comments if (c or "").strip()][:max_comments] if not filtered: return empty results: List[Dict[str, Any]] = [] for c in filtered: score = _score_comment(c) label = classify_comment(c) results.append({"text": c[:500], "score": round(score, 3), "label": label}) pos = [r for r in results if r["label"] == "positivo"] neg = [r for r in results if r["label"] == "negativo"] neu = [r for r in results if r["label"] == "neutro"] total = len(results) avg_score = round(sum(float(r["score"]) for r in results) / total, 3) counts = {"positivo": len(pos), "negativo": len(neg), "neutro": len(neu)} max_count = max(counts.values()) tied = [k for k, v in counts.items() if v == max_count] dominant = "neutro" if "neutro" in tied and len(tied) > 1 else tied[0] ejemplos = { "positivos": [r["text"] for r in sorted(pos, key=lambda x: -float(x["score"]))[:3]], "negativos": [r["text"] for r in sorted(neg, key=lambda x: float(x["score"]))[:3]], "neutros": [r["text"] for r in neu[:3]], } pos_texts = [r["text"] for r in pos] neu_texts = [r["text"] for r in neu] neg_texts = [r["text"] for r in neg] palabras_frecuentes = { "positivas": _top_words(pos_texts), "neutras": _top_words(neu_texts), "negativas": _top_words(neg_texts), } return { "total": total, "positivos": len(pos), "neutros": len(neu), "negativos": len(neg), "pct_positivo": round(len(pos) / total * 100, 1), "pct_neutro": round(len(neu) / total * 100, 1), "pct_negativo": round(len(neg) / total * 100, 1), "score_promedio": avg_score, "sentimiento_dominante": dominant, "ejemplos": ejemplos, "palabras_frecuentes": palabras_frecuentes, "resultados": results, "warning": "", } def fetch_and_analyze_comments( video_id: str, api_key: Optional[str] = None, max_results: int = 50, ) -> Dict[str, Any]: """Obtiene comentarios de YouTube Data API y analiza el sentimiento.""" if not api_key: return {**analyze_comments_sentiment([]), "warning": "Sin YOUTUBE_API_KEY: no se pudieron obtener comentarios."} import json as _json import urllib.parse import urllib.request url = ( "https://www.googleapis.com/youtube/v3/commentThreads?" + urllib.parse.urlencode({ "part": "snippet", "videoId": video_id, "maxResults": min(max_results, 100), "textFormat": "plainText", "key": api_key, }) ) try: with urllib.request.urlopen(url, timeout=10) as resp: data = _json.loads(resp.read().decode("utf-8")) comments = [ item["snippet"]["topLevelComment"]["snippet"].get("textDisplay", "") for item in data.get("items", []) ] result = analyze_comments_sentiment(comments, max_comments=max_results) result["source"] = "youtube_api" return result except Exception as exc: return { **analyze_comments_sentiment([]), "warning": f"Error obteniendo comentarios de YouTube: {exc}", } # --------------------------------------------------------------------------- # Markdown + visualizaciones para Gradio # --------------------------------------------------------------------------- def build_sentiment_markdown(sentiment: Dict[str, Any]) -> str: total = int(sentiment.get("total", 0) or 0) source = sentiment.get("source") or "manual/local" if total <= 0: warning = sentiment.get("warning") or "No hay comentarios suficientes para analizar." return f""" ### Análisis de sentimiento del público No se encontraron comentarios suficientes para construir una lectura confiable. **Fuente:** {source} **Estado:** {warning} ### Cómo usar este apartado Pega comentarios manualmente, uno por línea, o configura `YOUTUBE_API_KEY` para traer comentarios reales desde YouTube. """.strip() pct_pos = float(sentiment.get("pct_positivo", 0) or 0) pct_neu = float(sentiment.get("pct_neutro", 0) or 0) pct_neg = float(sentiment.get("pct_negativo", 0) or 0) dominant = sentiment.get("sentimiento_dominante", "neutro") avg = float(sentiment.get("score_promedio", 0) or 0) palabras = sentiment.get("palabras_frecuentes", {}) or {} top_pos = ", ".join([w for w, _ in palabras.get("positivas", [])[:10]]) or "sin señales claras" top_neu = ", ".join([w for w, _ in palabras.get("neutras", [])[:10]]) or "sin señales claras" top_neg = ", ".join([w for w, _ in palabras.get("negativas", [])[:10]]) or "sin señales claras" if dominant == "positivo": lectura = ( "La conversación tiene inclinación favorable. El contenido está generando señales de aceptación, " "utilidad o afinidad emocional. Conviene reforzar los temas que aparecen en la nube positiva." ) elif dominant == "negativo": lectura = ( "La conversación muestra fricción. Antes de escalar pauta, conviene revisar si las críticas se " "concentran en la promesa, claridad, credibilidad, precio, formato o expectativa del contenido." ) else: lectura = ( "La conversación es mayoritariamente neutral. Esto puede indicar baja emoción, comentarios informativos " "o una audiencia que todavía no expresa intención clara. El reto es convertir atención en reacción." ) ejemplos = sentiment.get("ejemplos", {}) or {} ejemplo_pos = "\n".join(f"- {x}" for x in ejemplos.get("positivos", [])[:2]) or "- Sin ejemplos positivos destacados." ejemplo_neu = "\n".join(f"- {x}" for x in ejemplos.get("neutros", [])[:2]) or "- Sin ejemplos neutros destacados." ejemplo_neg = "\n".join(f"- {x}" for x in ejemplos.get("negativos", [])[:2]) or "- Sin ejemplos negativos destacados." return f""" ### Resumen general Se analizaron **{total} comentarios** con un motor local de sentimiento. El sentimiento dominante es **{dominant}** y el score promedio es **{avg:.2f}** en una escala de -1 a +1. {lectura} ### Distribución del sentimiento | Sentimiento | Porcentaje | Menciones | |---|---:|---:| | Positivo | {pct_pos:.1f}% | {int(sentiment.get("positivos", 0) or 0)} | | Neutral | {pct_neu:.1f}% | {int(sentiment.get("neutros", 0) or 0)} | | Negativo | {pct_neg:.1f}% | {int(sentiment.get("negativos", 0) or 0)} | ### Insights del público por territorio emocional - **Territorio positivo:** {top_pos} - **Territorio neutral:** {top_neu} - **Territorio negativo:** {top_neg} ### Ejemplos representativos **Positivos** {ejemplo_pos} **Neutros** {ejemplo_neu} **Negativos** {ejemplo_neg} ### Recomendación accionable Usa el gráfico para medir el clima general y las nubes para encontrar conceptos repetidos. Las palabras positivas indican qué reforzar, las neutras qué explicar mejor y las negativas qué corregir antes de invertir más pauta. """.strip() def create_sentiment_visuals(sentiment: Dict[str, Any]) -> Dict[str, str]: """Genera gráfico de porcentajes y nubes de palabras por sentimiento. Devuelve rutas de imágenes para componentes gr.Image. No rompe la app si falta matplotlib/wordcloud; en ese caso devuelve placeholders vacíos o de aviso. """ try: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from wordcloud import WordCloud from .config import RUNTIME_CACHE_DIR except Exception: return { "sentiment_bar_chart": "", "wordcloud_positive": "", "wordcloud_neutral": "", "wordcloud_negative": "", } out_dir = RUNTIME_CACHE_DIR / "sentiment" out_dir.mkdir(parents=True, exist_ok=True) bar_path = out_dir / "sentiment_distribution.png" wc_pos_path = out_dir / "wordcloud_positive.png" wc_neu_path = out_dir / "wordcloud_neutral.png" wc_neg_path = out_dir / "wordcloud_negative.png" def _placeholder(path: Path, title: str, message: str) -> str: fig, ax = plt.subplots(figsize=(7, 4)) ax.axis("off") ax.text(0.5, 0.58, title, ha="center", va="center", fontsize=15, fontweight="bold") ax.text(0.5, 0.42, message, ha="center", va="center", fontsize=11, wrap=True) plt.tight_layout() plt.savefig(path, dpi=140, facecolor="white") plt.close(fig) return str(path) total = int(sentiment.get("total", 0) or 0) if total <= 0: msg = "Sin comentarios suficientes" return { "sentiment_bar_chart": _placeholder(bar_path, "Sentimiento", msg), "wordcloud_positive": _placeholder(wc_pos_path, "Palabras positivas", msg), "wordcloud_neutral": _placeholder(wc_neu_path, "Palabras neutras", msg), "wordcloud_negative": _placeholder(wc_neg_path, "Palabras negativas", msg), } labels = ["Positivo", "Neutral", "Negativo"] values = [ float(sentiment.get("pct_positivo", 0) or 0), float(sentiment.get("pct_neutro", 0) or 0), float(sentiment.get("pct_negativo", 0) or 0), ] colors = ["#22c55e", "#94a3b8", "#f87171"] fig, ax = plt.subplots(figsize=(7.5, 4.2)) bars = ax.bar(labels, values, color=colors) ax.set_ylim(0, 100) ax.set_ylabel("Porcentaje (%)") ax.set_title("Distribución porcentual del sentimiento", fontweight="bold") ax.grid(axis="y", alpha=0.25) for bar, value in zip(bars, values): ax.text(bar.get_x() + bar.get_width() / 2, value + 2, f"{value:.1f}%", ha="center", va="bottom", fontweight="bold") plt.tight_layout() plt.savefig(bar_path, dpi=140, facecolor="white") plt.close(fig) resultados = sentiment.get("resultados", []) or [] textos_pos = [r.get("text", "") for r in resultados if r.get("label") == "positivo"] textos_neu = [r.get("text", "") for r in resultados if r.get("label") == "neutro"] textos_neg = [r.get("text", "") for r in resultados if r.get("label") == "negativo"] def _wordcloud(textos: List[str], path: Path, title: str) -> str: tokens: List[str] = [] for texto in textos: tokens.extend(_tokens_sin_stopwords(texto)) if not tokens: return _placeholder(path, title, "Sin palabras suficientes") wc = WordCloud( width=900, height=500, background_color="white", max_words=100, collocations=False, stopwords=STOPWORDS_ES, ).generate(" ".join(tokens)) fig, ax = plt.subplots(figsize=(8, 4.5)) ax.imshow(wc, interpolation="bilinear") ax.axis("off") ax.set_title(title, fontsize=14, fontweight="bold") plt.tight_layout() plt.savefig(path, dpi=140, facecolor="white") plt.close(fig) return str(path) return { "sentiment_bar_chart": str(bar_path), "wordcloud_positive": _wordcloud(textos_pos, wc_pos_path, "Palabras positivas"), "wordcloud_neutral": _wordcloud(textos_neu, wc_neu_path, "Palabras neutras"), "wordcloud_negative": _wordcloud(textos_neg, wc_neg_path, "Palabras negativas"), }