From 77029fa8949b9f0fe59f8a03503a7726efb06081 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Wed, 12 Aug 2026 22:09:39 +0000 Subject: [PATCH] feat(youtube): la visibilidad se comprueba desde fuera, no se le cree a la API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/upload_short` contaba lo que la respuesta de la subida decía del vídeo. Eso es la palabra de la API sobre sí misma, y todo el flujo de revisión descansa en ella: el informe de fundamento se lee ANTES de publicar sólo si subir no publica. El 2026-08-12, mirando un vídeo recién subido, la respuesta decía `privacyStatus: private` y el vídeo se veía sin sesión — resultó ser un clic humano en Studio y no un fallo, pero el episodio dejó claro que no había forma de distinguir un caso del otro. Ahora se contrasta: oEmbed contesta 200 a un vídeo que se ve sin sesión y 404 a uno que no. Sin credenciales, sin tocar el scope — `youtube.upload` no puede preguntar por el estado de un vídeo, y ampliarlo a uno que sí pueda significa darle a un token de subida permiso para vaciar el canal. Dos decisiones que van con esto: - **Sólo el 200 es una prueba.** Un 404 no demuestra que el vídeo sea privado: también lo devuelve uno que YouTube aún no ha indexado. Por eso el negativo se mira dos veces y, si sigue negativo, se cuenta como "no se ve desde fuera", no como "es privado". - **No haber podido comprobar no es haber comprobado que no.** Un fallo de red deja `reachable=None` y el parte lo dice, en vez de heredar la garantía que no tiene. Cuando la API dice privado y el vídeo se ve, el aviso va en la PRIMERA línea del mensaje de Telegram: enterarse tiene que costar cero atención. Verificado contra la realidad — el mismo vídeo daba True antes de ocultarlo y False después; un vídeo borrado y uno privado dan False, y uno público True. Co-Authored-By: Claude Opus 5 --- src/bot/bot.py | 16 ++++- src/generator/youtube.py | 72 ++++++++++++++++++++- tests/test_bot_short_report.py | 34 ++++++++++ tests/test_youtube.py | 112 ++++++++++++++++++++++++++++++++- 4 files changed, 229 insertions(+), 5 deletions(-) diff --git a/src/bot/bot.py b/src/bot/bot.py index da5e423..8a91b5a 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -854,7 +854,13 @@ def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str: lines = [f"🎬 {video.title}", "", f"Revisar y publicar: {video.studio_url}", f"Enlace del vídeo: {video.watch_url}", ""] - if video.privacy_status == "private": + if video.visibility_contradiction: + # Primera línea del mensaje, no una nota al pie: si esto pasa, el vídeo + # ya está en la calle mientras lees el informe de fundamento. + lines.insert(0, "🚨 EL VÍDEO SE VE SIN INICIAR SESIÓN, aunque YouTube " + "dijo que lo subía en privado. Ocúltalo en Studio antes " + "de nada — el enlace de abajo lleva ahí.\n") + elif video.privacy_status == "private": lines.append( "🔒 Está PRIVADO. Los vídeos subidos por API desde un proyecto sin " "auditar se quedan así: el candado es del proyecto, no del vídeo, y " @@ -863,6 +869,14 @@ def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str: else: lines.append(f"👁 Visibilidad: {video.privacy_status}") + # Lo comprobado, aparte de lo que dijo la API: son dos cosas distintas y el + # 2026-08-12 se demostró que conviene no confundirlas. + if video.reachable is False: + lines.append("✔ Comprobado desde fuera: no se ve sin sesión.") + elif video.reachable is None: + lines.append("⚠️ No se pudo comprobar la visibilidad desde fuera; me " + "queda sólo lo que dijo la API. Míralo en Studio.") + if video.forced_private: lines.append("⚠️ Pediste otra visibilidad y YouTube la forzó a privada. " "Es exactamente la firma de ese candado.") diff --git a/src/generator/youtube.py b/src/generator/youtube.py index ba7f487..fbc9029 100644 --- a/src/generator/youtube.py +++ b/src/generator/youtube.py @@ -45,6 +45,15 @@ UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos" #: del canal: si el token se filtra, lo peor que se puede hacer con él es subir. SCOPE = "https://www.googleapis.com/auth/youtube.upload" +#: Con `youtube.upload` no se le puede PREGUNTAR a la API por el estado de un +#: vídeo, así que la visibilidad se comprueba desde fuera y sin credenciales: +#: oEmbed contesta 200 a un vídeo que se ve sin sesión y 401/404 a uno que no. +#: Es la única forma de contrastar lo que dice la respuesta de la subida sin +#: cambiar un token que sólo sabe subir por uno que puede vaciar el canal. +OEMBED_URL = "https://www.youtube.com/oembed" +#: Segunda pasada por si YouTube aún no había indexado el vídeo recién subido. +_VISIBILITY_RECHECK_DELAY = 3.0 + #: Márgen antes de que caduque el token de acceso (dura 3600 s). _TOKEN_MARGIN = 120.0 #: Tokens de acceso en memoria por client_id. El bot crea un uploader nuevo en @@ -97,6 +106,16 @@ class UploadedVideo: upload_status: str = "" #: Por qué YouTube marcó el vídeo como no reproducible, si lo hizo. rejection_reason: str = "" + #: Si el vídeo se ve sin iniciar sesión, comprobado desde fuera en vez de + #: creerle a la respuesta de la subida. None = no se pudo comprobar. + reachable: Optional[bool] = None + + @property + def visibility_contradiction(self) -> bool: + """La API dice privado y el vídeo se ve. Es el caso que hay que gritar: + todo el flujo de revisión — informe de fundamento primero, publicar + después — descansa en que subir NO publica.""" + return self.reachable is True and self.privacy_status == "private" @property def watch_url(self) -> str: @@ -336,11 +355,62 @@ class YouTubeUploader: rejection_reason=(status.get("rejectionReason") or status.get("failureReason") or ""), ) + await _report(on_progress, "🔎 Comprobando la visibilidad…") + result.reachable = await self.reachable(result.video_id) + logger.info("Short subido a YouTube", video_id=result.video_id, privacy=result.privacy_status, - forced_private=result.forced_private) + forced_private=result.forced_private, + reachable=result.reachable) + if result.visibility_contradiction: + logger.error("El vídeo se ve sin sesión y la API lo dio por privado", + video_id=result.video_id) return result + async def reachable(self, video_id: str) -> Optional[bool]: + """¿Se ve este vídeo sin haber iniciado sesión? + + True = cualquiera con el enlace lo ve. False = no. None = no se pudo + averiguar. + + **La asimetría es deliberada.** Un 200 PRUEBA que el vídeo es accesible; + un 404 no prueba que sea privado, porque también lo devuelve un vídeo + que YouTube todavía no ha terminado de indexar segundos después de + subirlo. Por eso sólo el 200 dispara un aviso, y por eso el negativo se + reintenta una vez antes de darlo por bueno. + + Nunca levanta: esto contrasta un dato, no lo produce. Si la red falla, el + vídeo ya está subido y lo que toca es decir que no se pudo comprobar — + no convertir una comprobación en el motivo de que la subida parezca + haber fallado. + """ + if not video_id: + return None + params = {"url": f"https://www.youtube.com/watch?v={video_id}", + "format": "json"} + seen: Optional[bool] = None + for attempt in (1, 2): + try: + async with self._session(20) as sess: + async with sess.get(OEMBED_URL, params=params) as resp: + status = resp.status + except (aiohttp.ClientError, OSError) as e: + logger.warning("No se pudo comprobar la visibilidad", + video_id=video_id, error=str(e)) + return seen + if status == 200: + return True + if status in (401, 403, 404): + seen = False + else: + logger.warning("oEmbed contestó algo inesperado", + video_id=video_id, status=status) + return seen + if attempt == 1: + import asyncio + await asyncio.sleep(_VISIBILITY_RECHECK_DELAY) + return seen + async def _start(self, token: str, metadata: dict, size: int) -> str: """Paso 1: los metadatos. Devuelve la URL de subida (cabecera Location).""" headers = { diff --git a/tests/test_bot_short_report.py b/tests/test_bot_short_report.py index c68f514..cac0d35 100644 --- a/tests/test_bot_short_report.py +++ b/tests/test_bot_short_report.py @@ -103,6 +103,40 @@ def test_upload_message_warns_when_the_description_has_no_article(): assert "force" in text +def test_upload_message_shouts_when_the_video_is_already_watchable(): + """El caso serio, y va PRIMERO en el mensaje. + + Si subir publica, el informe de fundamento se lee cuando el vídeo ya está en + la calle — el orden entero del flujo deja de significar nada. Enterarse + tiene que costar cero atención: en la primera línea o no sirve. + """ + from src.bot.bot import _upload_message + text = _upload_message(_uploaded(reachable=True), {}, "https://x.test/") + + assert "SE VE SIN INICIAR SESIÓN" in text.split("\n")[0] + assert "studio.youtube.com/video/abc123/edit" in text + # Y no se cuenta a la vez el cuento tranquilizador del candado. + assert "auditoría" not in text + + +def test_upload_message_confirms_a_video_nobody_can_watch(): + from src.bot.bot import _upload_message + text = _upload_message(_uploaded(reachable=False), {}, "https://x.test/") + + assert "no se ve sin sesión" in text + assert "SE VE SIN INICIAR SESIÓN" not in text + + +def test_upload_message_admits_when_it_could_not_check(): + """No haber podido comprobar no es haber comprobado que no. Decir "privado" + a secas aquí sería dar por garantía lo que sólo es la palabra de la API.""" + from src.bot.bot import _upload_message + text = _upload_message(_uploaded(reachable=None), {}, "https://x.test/") + + assert "No se pudo comprobar" in text + assert "Míralo en Studio" in text + + def test_upload_message_is_plain_text(): """Va sin parse_mode: lleva el título del modelo, y un Markdown roto haría que Telegram rechazara justo el mensaje que trae el enlace.""" diff --git a/tests/test_youtube.py b/tests/test_youtube.py index d861117..056e5e9 100644 --- a/tests/test_youtube.py +++ b/tests/test_youtube.py @@ -6,6 +6,7 @@ sube un vídeo a un canal de verdad, y eso no es algo que deba pasar por teclear """ import json +import aiohttp import pytest from src.generator import youtube as yt @@ -75,6 +76,9 @@ class FakeSession: def put(self, url, **kw): return self._next("PUT", url) + def get(self, url, **kw): + return self._next("GET", url) + @pytest.fixture(autouse=True) def clean_token_cache(): @@ -83,14 +87,33 @@ def clean_token_cache(): yt._token_cache.clear() +@pytest.fixture(autouse=True) +def no_recheck_delay(monkeypatch): + """La segunda pasada de la comprobación espera 3 s en producción, que es lo + que tarda YouTube en indexar. Aquí no se espera a nada.""" + monkeypatch.setattr(yt, "_VISIBILITY_RECHECK_DELAY", 0) + + @pytest.fixture def uploader(): return YouTubeUploader(client_id="cid", client_secret="secret", refresh_token="refresh") +#: Lo que oEmbed contesta de un vídeo que no se ve sin sesión. +OEMBED_HIDDEN = FakeResp(404, body="Not Found") +#: Y de uno que sí. +OEMBED_VISIBLE = FakeResp(200, {"title": "JAL 1628", "type": "video"}) + + def patch(client, routes): - session = FakeSession(routes) + """El servidor falso, con la comprobación de visibilidad ya enrutada. + + `upload()` la hace siempre, así que todo test que suba pasa por oEmbed. Por + defecto contesta "no se ve", que es lo que se espera de un vídeo privado; el + test que quiera el caso malo pone su propia ruta `/oembed`. + """ + session = FakeSession({"/oembed": OEMBED_HIDDEN, **routes}) client._session = lambda total: session return session @@ -170,8 +193,10 @@ async def test_upload_does_metadata_then_bytes(uploader, video): assert result.video_id == "abc123" assert result.watch_url == "https://youtube.com/shorts/abc123" assert result.studio_url.endswith("/abc123/edit") - assert [c[0] for c in session.calls] == ["POST", "POST", "PUT"] - assert len(seen) == 3, "cada etapa avisa: autenticar, abrir, subir" + # Token, metadatos, bytes, y la comprobación de visibilidad — que se + # reintenta porque el primer 404 puede ser YouTube todavía indexando. + assert [c[0] for c in session.calls] == ["POST", "POST", "PUT", "GET", "GET"] + assert len(seen) == 4, "cada etapa avisa: autenticar, abrir, subir, comprobar" @pytest.mark.asyncio @@ -339,3 +364,84 @@ def test_uploaded_video_urls(): video = UploadedVideo(video_id="xyz", title="t", privacy_status="private") assert video.watch_url == "https://youtube.com/shorts/xyz" assert video.studio_url == "https://studio.youtube.com/video/xyz/edit" + + +# --- la visibilidad, comprobada en vez de creída ---------------------------- + +@pytest.mark.asyncio +async def test_a_video_anyone_can_watch_is_detected(uploader, video): + """El caso que existe para pillar: la API dice privado y el vídeo se ve. + + Todo el flujo de revisión — informe de fundamento primero, publicar después + — descansa en que subir NO publique. Si eso deja de ser cierto hay que + enterarse por el parte de la subida, no por una visita al canal. + """ + patch(uploader, { + "/token": TOKEN_OK, + "/upload/youtube": FakeResp(200, {}, headers={"Location": "https://up/x"}), + "https://up/x": FakeResp(200, VIDEO_OK), + "/oembed": OEMBED_VISIBLE, + }) + + result = await uploader.upload(video, build_metadata(SPEC, "x")) + + assert result.privacy_status == "private", "la API sigue diciendo privado" + assert result.reachable is True + assert result.visibility_contradiction + + +@pytest.mark.asyncio +async def test_a_private_video_reports_no_contradiction(uploader, video): + patch(uploader, { + "/token": TOKEN_OK, + "/upload/youtube": FakeResp(200, {}, headers={"Location": "https://up/x"}), + "https://up/x": FakeResp(200, VIDEO_OK), + }) + + result = await uploader.upload(video, build_metadata(SPEC, "x")) + + assert result.reachable is False + assert not result.visibility_contradiction + + +@pytest.mark.asyncio +async def test_a_video_visible_only_on_the_second_look_still_counts(uploader): + """Segundos después de subirlo, oEmbed devuelve 404 de un vídeo que sí se + ve: aún no está indexado. Un solo vistazo daría por privado justo el vídeo + que hay que gritar.""" + session = patch(uploader, {"/oembed": [OEMBED_HIDDEN, OEMBED_VISIBLE]}) + + assert await uploader.reachable("abc123") is True + assert len(session.calls) == 2 + + +@pytest.mark.asyncio +async def test_two_hidden_looks_are_enough_to_stop_asking(uploader): + session = patch(uploader, {"/oembed": OEMBED_HIDDEN}) + + assert await uploader.reachable("abc123") is False + assert len(session.calls) == 2 + + +@pytest.mark.asyncio +async def test_a_network_failure_is_not_knowing_rather_than_privacy(uploader): + """No se pudo comprobar NO es lo mismo que no se ve. Devolver False aquí + sería inventarse una garantía a partir de un fallo de red.""" + class Broken: + async def __aenter__(self): return self + async def __aexit__(self, *a): return False + + def get(self, url, **kw): + raise aiohttp.ClientError("sin red") + + uploader._session = lambda total: Broken() + + assert await uploader.reachable("abc123") is None + + +@pytest.mark.asyncio +async def test_an_upload_without_an_id_is_not_checked(uploader): + session = patch(uploader, {"/oembed": OEMBED_VISIBLE}) + + assert await uploader.reachable("") is None + assert session.calls == []