feat(youtube): la visibilidad se comprueba desde fuera, no se le cree a la API
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
`/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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user