diff --git a/README.md b/README.md
index cff3cd4..831c5ee 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,9 @@ One-time setup, in [console.cloud.google.com](https://console.cloud.google.com):
3. Credentials → OAuth client ID → **Desktop app**.
4. `python scripts/youtube_oauth.py --client-id … --client-secret …`, which
opens a browser, catches the redirect on localhost and prints the refresh
- token.
+ token. Add `--paste` when the browser is on another device (an iPad, say):
+ the final redirect tab fails to load — nothing listens there, that is
+ expected — and you paste its full URL back into the terminal.
5. Put `youtube-client-id`, `youtube-client-secret` and `youtube-refresh-token`
into Infisical (they arrive as `researchowl-secrets-infisical`).
diff --git a/scripts/youtube_oauth.py b/scripts/youtube_oauth.py
index af72d6a..6c67073 100644
--- a/scripts/youtube_oauth.py
+++ b/scripts/youtube_oauth.py
@@ -6,6 +6,14 @@
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`.
+Con `--paste` no levanta servidor local: da permiso desde CUALQUIER dispositivo
+(un iPad vale), deja que la pestaña de redirección falle al cargar — no hay
+nadie escuchando, es lo esperado — y pega aquí la URL completa de la barra de
+direcciones, que lleva el `code=` dentro. También se puede canalizar por stdin:
+
+ echo 'http://localhost:8765/?code=4/0Ab...' | \\
+ python scripts/youtube_oauth.py --paste --client-id XXX --client-secret YYY
+
Sólo stdlib: esto corre fuera del contenedor, en el portátil de quien lo lance,
y no debería exigir instalar nada.
@@ -44,6 +52,10 @@ 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"
+# En modo --paste nadie escucha en el puerto, pero el redirect_uri del
+# intercambio tiene que ser IDÉNTICO al de la autorización, así que va fijo.
+PASTE_PORT = 8765
+
_PAGE = """
ResearchOwl
@@ -81,6 +93,18 @@ def _free_port() -> int:
return s.getsockname()[1]
+def _code_from_paste(text: str) -> str:
+ """Saca el `code` de lo que pegue el usuario: la URL de la redirección
+ entera, o el código a secas si fue más listo que el script."""
+ text = text.strip().strip('"').strip("'")
+ if not text:
+ return ""
+ if "?" in text or text.startswith(("http", "localhost")):
+ query = urllib.parse.urlparse(text).query
+ return urllib.parse.parse_qs(query).get("code", [""])[0]
+ return text
+
+
def _post_form(url: str, data: dict) -> dict:
payload = urllib.parse.urlencode(data).encode()
request = urllib.request.Request(
@@ -102,13 +126,17 @@ def main() -> int:
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")
+ parser.add_argument("--paste", action="store_true",
+ help="sin servidor local: autoriza desde cualquier "
+ "dispositivo y pega aquí la URL de la redirección "
+ "(la pestaña dará error de conexión; es normal)")
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()
+ port = PASTE_PORT if args.paste else _free_port()
redirect_uri = f"http://localhost:{port}"
auth_url = f"{AUTH_URL}?" + urllib.parse.urlencode({
"client_id": args.client_id,
@@ -122,27 +150,46 @@ def main() -> int:
"prompt": "consent",
})
- server = http.server.HTTPServer(("127.0.0.1", port), _Catcher)
- waiter = threading.Thread(target=server.handle_request, daemon=True)
- waiter.start()
+ if args.paste:
+ print(f"\nAbre esto EN CUALQUIER DISPOSITIVO y da permiso (verás un "
+ f"aviso de app no verificada; «Advanced» → continuar):\n\n"
+ f" {auth_url}\n\n"
+ f"La pestaña final fallará al cargar (localhost:{port} no existe "
+ f"ahí) — es lo esperado. Copia la URL COMPLETA de la barra de "
+ f"direcciones. El código caduca en unos 10 minutos.")
+ try:
+ pasted = input("\nPega aquí la URL de la redirección: ")
+ except EOFError:
+ pasted = ""
+ code = _code_from_paste(pasted)
+ if not code:
+ print("\n❌ Ahí no venía ningún `code`. Vuelve a lanzar y pega la "
+ "URL entera de la barra de direcciones (empieza por "
+ "http://localhost:…).", file=sys.stderr)
+ return 1
+ else:
+ 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)")
+ 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
+ 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
+ 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,