feat(youtube): subir Shorts al canal con /upload_short
Fase 3, con una corrección sobre lo que decía la §11 de la spec de fase 2. El bloqueo no es OAuth. Los vídeos subidos por videos.insert desde un proyecto de API sin auditar quedan restringidos a privado, y el candado es del proyecto, no del vídeo: no se abre desde Studio, se abre pasando la auditoría de cumplimiento de Google. Así que esto no publica. Deja el vídeo en el canal con los metadatos puestos y devuelve el enlace de Studio para que una persona lo revise y le dé a publicar — la misma forma que /publish con los borradores de Ghost, y por la misma razón: el informe de fundamento no sirve de nada si el vídeo ya está subido cuando lo lees. Comando aparte, no un paso de /generate short_en. - src/generator/youtube.py: refresco de token contra oauth2.googleapis.com, subida resumable en dos pasos y metadatos derivados del shot spec ya guardado (título, enlace al artículo, fuentes que el Short cita en pantalla, etiquetas del tema). Sin google-api-python-client: es síncrono y bloquearía el loop del bot; son dos peticiones HTTP y el repo ya firma los JWT de Ghost a mano. aiohttp con SAFE_ACCEPT_ENCODING como todo lo demás. - Scope youtube.upload y nada más: un token filtrado no puede leer ni borrar nada del canal, sólo subir. - forced_private detecta que YouTube devolvió "private" cuando se pidió otra cosa, y el aviso lo dice. Es la firma del candado, y tragárselo haría creer que salió publicado. - invalid_grant se traduce a su causa real: la pantalla de consentimiento quedó en "Testing" y Google revoca esos tokens a los siete días. Es el fallo que menos se adivina y el que más probable es encontrarse. - get_article_url ahora excluye las filas short_en. Su published_url pasa a ser la URL de YouTube, y sin el filtro el siguiente Short de la sesión enlazaría al Short anterior: un bucle silencioso, porque la URL es válida y nadie la mira dos veces. - scripts/youtube_oauth.py, sólo stdlib: corre en el portátil, no en el contenedor, y no debería exigir instalar nada. - Las tres claves van optional:true en el Deployment. Sin eso, una clave que aún no está en Infisical deja el pod en CreateContainerConfigError y tira el bot entero por una función que nadie ha pedido todavía. 30 tests nuevos contra un servidor falso. No hay test en vivo a propósito: cualquier ejecución real sube un vídeo a un canal de verdad, y eso no es algo que deba pasar por teclear pytest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Saca el refresh token de YouTube. Se ejecuta UNA vez, en tu máquina.
|
||||
|
||||
python scripts/youtube_oauth.py --client-id XXX --client-secret YYY
|
||||
|
||||
Abre el navegador, te pide permiso para subir vídeos a tu canal, y escupe el
|
||||
refresh token para meterlo en Infisical como `youtube-refresh-token`.
|
||||
|
||||
Sólo stdlib: esto corre fuera del contenedor, en el portátil de quien lo lance,
|
||||
y no debería exigir instalar nada.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
Antes de ejecutarlo, en https://console.cloud.google.com:
|
||||
|
||||
1. Crea un proyecto (o usa uno) y habilita **YouTube Data API v3**.
|
||||
2. Pantalla de consentimiento OAuth → External.
|
||||
3. **Pásala a «In production»**, no la dejes en «Testing». En Testing, Google
|
||||
revoca los refresh tokens a los SIETE DÍAS y el bot se cae solo el martes que
|
||||
viene. Al publicarla verás un aviso de "app no verificada" al dar permiso;
|
||||
con «Advanced → continuar» basta, porque el único usuario eres tú.
|
||||
4. Credenciales → Crear → ID de cliente OAuth → tipo **Aplicación de escritorio**.
|
||||
Ese tipo acepta redirecciones a localhost en cualquier puerto, que es lo que
|
||||
usa este script.
|
||||
|
||||
Y lo que conviene saber antes de invertir la tarde: los vídeos subidos por API
|
||||
desde un proyecto sin auditar quedan **restringidos a privado**, y el candado es
|
||||
del proyecto, no del vídeo. Se levanta pasando la auditoría de cumplimiento de
|
||||
Google, no desde Studio. Con esto el bot te deja el vídeo en el canal con los
|
||||
metadatos puestos; publicarlo sigue siendo un clic tuyo.
|
||||
"""
|
||||
import argparse
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
|
||||
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
SCOPE = "https://www.googleapis.com/auth/youtube.upload"
|
||||
|
||||
_PAGE = """<!doctype html><meta charset="utf-8">
|
||||
<title>ResearchOwl</title>
|
||||
<body style="font-family:system-ui;max-width:32rem;margin:6rem auto;line-height:1.6">
|
||||
<h1>{heading}</h1><p>{body}</p></body>"""
|
||||
|
||||
|
||||
class _Catcher(http.server.BaseHTTPRequestHandler):
|
||||
"""Recoge el ?code= de la redirección y para."""
|
||||
|
||||
result: dict = {}
|
||||
|
||||
def do_GET(self):
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
_Catcher.result = {k: v[0] for k, v in query.items()}
|
||||
ok = "code" in _Catcher.result
|
||||
page = _PAGE.format(
|
||||
heading="✅ Listo" if ok else "❌ Algo falló",
|
||||
body=("Ya puedes cerrar esta pestaña y volver a la terminal."
|
||||
if ok else
|
||||
f"Google devolvió: {_Catcher.result.get('error', 'sin código')}"))
|
||||
body = page.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass # sin ruido de servidor en la terminal
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _post_form(url: str, data: dict) -> dict:
|
||||
payload = urllib.parse.urlencode(data).encode()
|
||||
request = urllib.request.Request(
|
||||
url, data=payload,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode("utf-8", "replace")
|
||||
raise SystemExit(f"\n❌ Google rechazó el intercambio ({e.code}):\n{detail}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Obtiene el refresh token de YouTube para ResearchOwl.")
|
||||
parser.add_argument("--client-id", default=os.environ.get("YOUTUBE_CLIENT_ID"))
|
||||
parser.add_argument("--client-secret",
|
||||
default=os.environ.get("YOUTUBE_CLIENT_SECRET"))
|
||||
parser.add_argument("--no-browser", action="store_true",
|
||||
help="no abrir el navegador, sólo imprimir la URL")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.client_id or not args.client_secret:
|
||||
parser.error("hacen falta --client-id y --client-secret "
|
||||
"(o YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET en el entorno)")
|
||||
|
||||
port = _free_port()
|
||||
redirect_uri = f"http://localhost:{port}"
|
||||
auth_url = f"{AUTH_URL}?" + urllib.parse.urlencode({
|
||||
"client_id": args.client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": SCOPE,
|
||||
# Los dos juntos, y no por gusto: sin access_type=offline no hay refresh
|
||||
# token, y sin prompt=consent Google deja de mandarlo en cuanto ya diste
|
||||
# permiso una vez — el fallo clásico de "me sale null la segunda vez".
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
})
|
||||
|
||||
server = http.server.HTTPServer(("127.0.0.1", port), _Catcher)
|
||||
waiter = threading.Thread(target=server.handle_request, daemon=True)
|
||||
waiter.start()
|
||||
|
||||
print(f"\nAbre esto y da permiso (verás un aviso de app no verificada; "
|
||||
f"«Advanced» → continuar):\n\n {auth_url}\n")
|
||||
if not args.no_browser:
|
||||
webbrowser.open(auth_url)
|
||||
print("Esperando la redirección… (Ctrl-C para abortar)")
|
||||
|
||||
waiter.join(timeout=300)
|
||||
server.server_close()
|
||||
if waiter.is_alive():
|
||||
print("\n❌ Nadie llegó a la redirección en 5 minutos.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
code = _Catcher.result.get("code")
|
||||
if not code:
|
||||
print(f"\n❌ Sin código. Google dijo: "
|
||||
f"{_Catcher.result.get('error', '(nada)')}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
tokens = _post_form(TOKEN_URL, {
|
||||
"code": code,
|
||||
"client_id": args.client_id,
|
||||
"client_secret": args.client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
})
|
||||
|
||||
refresh = tokens.get("refresh_token")
|
||||
if not refresh:
|
||||
print("\n❌ Google no devolvió refresh_token. Suele pasar cuando ya "
|
||||
"habías dado permiso antes: revoca el acceso en "
|
||||
"https://myaccount.google.com/permissions y repite.",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("\n✅ Refresh token:\n")
|
||||
print(f" {refresh}\n")
|
||||
print("Mételo en Infisical (proyecto researchowl) como:\n")
|
||||
print(" youtube-client-id =", args.client_id)
|
||||
print(" youtube-client-secret =", args.client_secret)
|
||||
print(" youtube-refresh-token =", refresh)
|
||||
print("\nRecuerda: si la pantalla de consentimiento sigue en «Testing», "
|
||||
"este token caduca en 7 días.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user