Spaces:
Running
Running
| import os, re, time, asyncio, threading, httpx | |
| from fastapi import FastAPI, Request, BackgroundTasks | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| app = FastAPI() | |
| async def iniciar_pings(): | |
| import asyncio, httpx | |
| async def ping_loop(): | |
| while True: | |
| await asyncio.sleep(21600) # 6 horas | |
| try: | |
| async with httpx.AsyncClient(timeout=15) as client: | |
| for email, datos in list(CLIENTES.items()): | |
| url = datos.get("url", "") | |
| if url: | |
| try: | |
| await client.get(url) | |
| print(f"Ping OK: {url}", flush=True) | |
| except Exception as e: | |
| print(f"Ping fail {url}: {e}", flush=True) | |
| except Exception as ex: | |
| print(f"Error ping loop: {ex}", flush=True) | |
| asyncio.create_task(ping_loop()) | |
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) | |
| HF_USER = os.environ.get("HF_USER", "Cristobal299") | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| GROQ_KEY = os.environ.get("GROQ_KEY", "") | |
| PEXELS_KEY = os.environ.get("PEXELS_KEY", "") | |
| STRIPE_KEY = os.environ.get("STRIPE_KEY", "") | |
| STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "") | |
| TOKENS_VALIDOS = {} | |
| async def stripe_webhook(request: Request): | |
| import hmac, hashlib, secrets | |
| payload = await request.body() | |
| sig = request.headers.get("stripe-signature","") | |
| try: | |
| elements = {k:v for k,v in [x.split("=",1) for x in sig.split(",")]} | |
| ts = elements.get("t","") | |
| expected = hmac.new(STRIPE_WEBHOOK_SECRET.encode(), f"{ts}.{payload.decode()}".encode(), hashlib.sha256).hexdigest() | |
| if not hmac.compare_digest(expected, elements.get("v1","")): | |
| return JSONResponse({"error":"firma invalida"}, status_code=400) | |
| except: | |
| return JSONResponse({"error":"error verificacion"}, status_code=400) | |
| import json | |
| data = json.loads(payload) | |
| if data.get("type") == "checkout.session.completed": | |
| email = data["data"]["object"].get("customer_email","") | |
| token = secrets.token_urlsafe(32) | |
| TOKENS_VALIDOS[token] = email | |
| return {"ok": True} | |
| RESEND_KEY = os.environ.get("RESEND_KEY", "") | |
| def enviar_email(email, nicho, url): | |
| try: | |
| import requests | |
| html = f""" | |
| <div style="background:#0a0a0a;color:#fff;padding:40px;font-family:sans-serif;max-width:600px;margin:0 auto;"> | |
| <h1 style="color:#ff2d2d;">🤖 TubeBot activado</h1> | |
| <p>Tu canal de <strong>{nicho}</strong> ya está funcionando.</p> | |
| <p style="margin:20px 0;"> | |
| <a href="{url}" style="background:#ff2d2d;color:#fff;padding:14px 28px;border-radius:6px;text-decoration:none;font-weight:bold;"> | |
| Ver mi sistema en tiempo real | |
| </a> | |
| </p> | |
| <p style="color:#666;font-size:.85rem;">Si tienes dudas responde este email.</p> | |
| </div>""" | |
| requests.post("https://api.brevo.com/v3/smtp/email", | |
| headers={"api-key": os.environ.get("BREVO_KEY",""), "Content-Type": "application/json", "accept": "application/json"}, | |
| json={ | |
| "sender": {"name": "TubeBot", "email": "dayron100813@gmail.com"}, | |
| "to": [{"email": email}], | |
| "subject": "Tu canal de YouTube ya esta listo", | |
| "htmlContent": html | |
| } | |
| ) | |
| except Exception as e: | |
| print(f"Error email: {e}") | |
| CLIENTES = {} | |
| def _enviar_aviso_real(asunto, mensaje): | |
| try: | |
| import requests as _r | |
| html = mensaje.replace("<b>", "<strong>").replace("</b>", "</strong>").replace("\n", "<br>") | |
| _r.post("https://api.brevo.com/v3/smtp/email", | |
| headers={"api-key": os.environ.get("BREVO_KEY",""), "Content-Type": "application/json", "accept": "application/json"}, | |
| json={ | |
| "sender": {"name": "TubeBot Avisos", "email": "dayron100813@gmail.com"}, | |
| "to": [{"email": "Sicariodedios69@gmail.com"}], | |
| "subject": asunto, | |
| "htmlContent": f"<div style='font-family:sans-serif;background:#0a0a0a;color:#fff;padding:20px'>{html}</div>" | |
| }, | |
| timeout=30 | |
| ) | |
| except Exception as e: | |
| print(f"Error aviso: {e}", flush=True) | |
| def avisar_telegram(mensaje): | |
| """Manda aviso por Gmail (Brevo) en thread aparte""" | |
| import threading | |
| asunto_corto = mensaje.split("\n")[0].replace("<b>", "").replace("</b>", "").strip() | |
| threading.Thread(target=_enviar_aviso_real, args=(asunto_corto[:100], mensaje), daemon=True).start() | |
| def cargar_clientes(): | |
| global CLIENTES | |
| try: | |
| import json | |
| r = __import__("requests").get( | |
| f"https://huggingface.co/api/repos/Cristobal299/IAweb/raw/main/clientes.json", | |
| headers={"Authorization": f"Bearer {HF_TOKEN}"} | |
| ) | |
| if r.status_code == 200: | |
| CLIENTES = r.json() | |
| except: | |
| pass | |
| def guardar_clientes(): | |
| try: | |
| import json | |
| data = json.dumps(CLIENTES).encode() | |
| __import__("requests").put( | |
| f"https://huggingface.co/api/repos/Cristobal299/IAweb/upload/main/clientes.json", | |
| headers={"Authorization": f"Bearer {HF_TOKEN}", "Content-Type": "application/octet-stream"}, | |
| data=data | |
| ) | |
| except Exception as e: | |
| print(f"Error guardando clientes: {e}") | |
| cargar_clientes() | |
| # ══════════════════════════════════════════ | |
| # CREAR SPACE EN HUGGING FACE | |
| # ══════════════════════════════════════════ | |
| def token_de_repo(repo): | |
| """Del repo (ej Maria288/tb-xyz) saca el usuario y devuelve su token HF""" | |
| usuario = repo.split("/")[0] if "/" in repo else "" | |
| mapa = { | |
| os.environ.get("HF_USER_5","Alejandro"): os.environ.get("HF_TOKEN_5",""), | |
| os.environ.get("HF_USER_4","Maria288"): os.environ.get("HF_TOKEN_4",""), | |
| os.environ.get("HF_USER_2","Cristobal2999"): os.environ.get("HF_TOKEN_2",""), | |
| os.environ.get("HF_USER_3","Cristobal288"): os.environ.get("HF_TOKEN_3",""), | |
| } | |
| return mapa.get(usuario, "") | |
| async def crear_space(nicho: str, email: str, tipo: str = "largo", config_parsed: dict = None, archivos_subidos: list = None) -> str: | |
| space_id = f"tb-{re.sub(r'[^a-z0-9]','', nicho.lower()[:12])}-{int(time.time())%9999}" | |
| cuentas = [ | |
| (os.environ.get("HF_TOKEN_5",""), os.environ.get("HF_USER_5","Alejandro")), | |
| (os.environ.get("HF_TOKEN_4",""), os.environ.get("HF_USER_4","Maria288")), | |
| (os.environ.get("HF_TOKEN_2",""), os.environ.get("HF_USER_2","Cristobal2999")), | |
| (os.environ.get("HF_TOKEN_3",""), os.environ.get("HF_USER_3","Cristobal288")), | |
| ] | |
| cuentas = [(t,u) for t,u in cuentas if t] | |
| import random | |
| token_actual, hf_user_actual = random.choice(cuentas) | |
| print(f"DEBUG user={hf_user_actual} token={token_actual[:8]}", flush=True) | |
| headers = {"Authorization": f"Bearer {token_actual}", "Content-Type": "application/json"} | |
| with open("cliente_template.py", "r") as _f: | |
| main_code = _f.read().replace("NICHO_PLACEHOLDER", nicho).replace("EMAIL_PLACEHOLDER", email).replace("REPO_PLACEHOLDER", f"{hf_user_actual}/{space_id}").replace("GROQ_PLACEHOLDER", os.environ.get("GROQ_KEY","")).replace("GROQ_PLACEHOLDER_2", os.environ.get("GROQ_KEY_2","")).replace("GROQ_PLACEHOLDER_3", os.environ.get("GROQ_KEY_3","")).replace("PEXELS_PLACEHOLDER", os.environ.get("PEXELS_KEY","")).replace("TIPO_PLACEHOLDER", tipo).replace("CONFIG_PLACEHOLDER", __import__("json").dumps(config_parsed or {})) | |
| req = "gradio\ngroq\nedge-tts\nmoviepy==1.0.3\nrequests\nhttpx\ngoogle-api-python-client\ngoogle-auth\ngoogle-auth-oauthlib\nimageio\nimageio-ffmpeg\ndecorator\ntqdm\nPillow\nnumpy\nopencv-python-headless" | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| # Crear repo | |
| r = await client.post("https://huggingface.co/api/repos/create", | |
| headers=headers, | |
| json={"name": space_id, "type": "space", "sdk": "docker", "private": False}) | |
| repo_name = r.json().get("name", f"{hf_user_actual}/{space_id}") | |
| if r.status_code not in [200, 201]: | |
| print(f"Error repo: {r.text}") | |
| return None | |
| repo_name = r.json().get("name", f"{hf_user_actual}/{space_id}") | |
| print(f"Repo OK: {repo_name}", flush=True) | |
| # Subir archivos via commit API | |
| import base64, json as _json | |
| def b64(s): return base64.b64encode(s.encode("utf-8")).decode("utf-8") | |
| readme = """--- | |
| title: TubeBot | |
| emoji: 🤖 | |
| colorFrom: red | |
| colorTo: gray | |
| sdk: docker | |
| pinned: false | |
| ---""" | |
| dockerfile = """FROM python:3.10-slim | |
| RUN apt-get update && apt-get install -y imagemagick ffmpeg && rm -rf /var/lib/apt/lists/* | |
| RUN sed -i "s/rights=\"none\"/rights=\"read|write\"/g" /etc/ImageMagick-7/policy.xml || true | |
| WORKDIR /app | |
| COPY requirements.txt . | |
| RUN pip install -r requirements.txt | |
| COPY main.py . | |
| COPY media_* ./ | |
| EXPOSE 7860 | |
| CMD [\"uvicorn\", \"main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"7860\"]""" | |
| commit_payload = { | |
| "summary": "TubeBot init", | |
| "files": [ | |
| {"path": "main.py", "content": b64(main_code), "encoding": "base64"}, | |
| {"path": "requirements.txt", "content": b64(req), "encoding": "base64"}, | |
| {"path": "README.md", "content": b64(readme), "encoding": "base64"}, | |
| {"path": "Dockerfile", "content": b64(dockerfile), "encoding": "base64"} | |
| ] | |
| } | |
| r2 = await client.post( | |
| f"https://huggingface.co/api/spaces/{repo_name}/commit/main", | |
| headers={"Authorization": f"Bearer {token_actual}", "Content-Type": "application/json"}, | |
| content=_json.dumps(commit_payload).encode() | |
| ) | |
| print(f"Commit: {r2.status_code} {r2.text[:200]}") | |
| # Subir archivos del cliente uno a uno (evita problema de binarios) | |
| if archivos_subidos: | |
| for i, arc in enumerate(archivos_subidos[:20]): | |
| try: | |
| nombre = arc.get("nombre", f"media_{i}.jpg").replace("/","_").replace("\\","_") | |
| contenido_b64 = arc.get("contenido", "") | |
| if not contenido_b64: continue | |
| binario = base64.b64decode(contenido_b64) | |
| commit_arc = { | |
| "summary": f"upload {nombre}", | |
| "files": [ | |
| {"path": f"media_{nombre}", "content": contenido_b64, "encoding": "base64"} | |
| ] | |
| } | |
| # 3 intentos con espera | |
| for intento in range(3): | |
| r_up = await client.post( | |
| f"https://huggingface.co/api/spaces/{repo_name}/commit/main", | |
| headers={"Authorization": f"Bearer {token_actual}", "Content-Type": "application/json"}, | |
| content=_json.dumps(commit_arc).encode() | |
| ) | |
| if r_up.status_code in [200, 201]: | |
| break | |
| await asyncio.sleep(2) | |
| print(f"Upload {nombre}: {r_up.status_code}", flush=True) | |
| except Exception as e: | |
| print(f"Error upload {arc.get('nombre','?')}: {e}", flush=True) | |
| # Agregar secretos al Space del cliente | |
| secretos = { | |
| "GROQ_KEY": os.environ.get("GROQ_KEY",""), | |
| "PEXELS_KEY": os.environ.get("PEXELS_KEY",""), | |
| "GOOGLE_CLIENT_ID": os.environ.get("GOOGLE_CLIENT_ID",""), | |
| "GOOGLE_CLIENT_SECRET": os.environ.get("GOOGLE_CLIENT_SECRET",""), | |
| "CLIENT_EMAIL": email, | |
| } | |
| for key, val in secretos.items(): | |
| await client.post( | |
| f"https://huggingface.co/api/spaces/{hf_user_actual}/{space_id}/secrets", | |
| headers={"Authorization": f"Bearer {token_actual}", "Content-Type": "application/json"}, | |
| json={"key": key, "value": val} | |
| ) | |
| url = f"https://{hf_user_actual.lower()}-{space_id.lower()}.hf.space" | |
| return {"url": url, "repo": repo_name} | |
| # ══════════════════════════════════════════ | |
| # PÁGINA PRINCIPAL — VENTAS | |
| # ══════════════════════════════════════════ | |
| VENTAS_HTML = """<!DOCTYPE html> | |
| <html lang="es"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>TubeBot — Canal de YouTube Automático</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Manrope:wght@300;400;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{--r:#ff2d2d;--bg:#080808;--card:#111;--b:#1e1e1e;--t:#f0f0f0;--m:#666;} | |
| *{box-sizing:border-box;margin:0;padding:0;} | |
| html{scroll-behavior:smooth;} | |
| body{background:var(--bg);color:var(--t);font-family:'Manrope',sans-serif;overflow-x:hidden;} | |
| .hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:40px 20px;background:radial-gradient(ellipse 80% 50% at 50% 0%,#2a0000,transparent 70%);} | |
| .badge{display:inline-block;background:#1a0000;border:1px solid var(--r);color:var(--r);font-size:.7rem;letter-spacing:3px;text-transform:uppercase;padding:6px 16px;border-radius:100px;margin-bottom:28px;animation:fd .6s ease both;} | |
| h1{font-family:'Bebas Neue',sans-serif;font-size:clamp(3.5rem,12vw,8rem);line-height:.9;animation:fd .6s .1s ease both;opacity:0;} | |
| h1 span{color:var(--r);} | |
| .sub{font-size:clamp(1rem,2.5vw,1.3rem);color:#aaa;font-weight:300;max-width:560px;margin:24px auto 0;line-height:1.6;animation:fd .6s .2s ease both;opacity:0;} | |
| .hero-cta{margin-top:40px;animation:fd .6s .3s ease both;opacity:0;} | |
| .btn{display:inline-block;background:var(--r);color:#fff;font-family:'Manrope',sans-serif;font-weight:800;font-size:1.1rem;padding:18px 44px;border-radius:6px;text-decoration:none;cursor:pointer;border:none;transition:all .2s;} | |
| .btn:hover{background:#cc0000;transform:translateY(-2px);box-shadow:0 8px 30px rgba(255,45,45,.4);} | |
| .price-note{display:block;margin-top:14px;color:var(--m);font-size:.85rem;} | |
| .price-note strong{color:var(--t);} | |
| .stats{display:flex;justify-content:center;gap:clamp(20px,5vw,60px);flex-wrap:wrap;padding:60px 20px;border-top:1px solid var(--b);border-bottom:1px solid var(--b);background:#0d0d0d;} | |
| .stat-n{font-family:'Bebas Neue',sans-serif;font-size:clamp(2.5rem,6vw,4rem);color:var(--r);line-height:1;} | |
| .stat-l{font-size:.8rem;color:var(--m);letter-spacing:2px;text-transform:uppercase;margin-top:4px;} | |
| .sec{padding:80px 20px;max-width:900px;margin:0 auto;} | |
| .sec-label{font-size:.72rem;letter-spacing:4px;text-transform:uppercase;color:var(--r);margin-bottom:16px;font-weight:700;} | |
| .sec h2{font-family:'Bebas Neue',sans-serif;font-size:clamp(2.5rem,6vw,4.5rem);line-height:1;margin-bottom:48px;} | |
| .steps{display:flex;flex-direction:column;gap:16px;} | |
| .step{display:flex;align-items:flex-start;gap:20px;background:var(--card);border:1px solid var(--b);border-radius:12px;padding:24px;} | |
| .step-n{font-family:'Bebas Neue',sans-serif;font-size:2.5rem;color:var(--r);line-height:1;min-width:48px;} | |
| .step-title{font-weight:700;margin-bottom:6px;} | |
| .step-desc{color:#888;font-size:.9rem;line-height:1.6;} | |
| .feats{padding:80px 20px;background:#0d0d0d;border-top:1px solid var(--b);border-bottom:1px solid var(--b);} | |
| .feats-inner{max-width:900px;margin:0 auto;} | |
| .feat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:16px;margin-top:48px;} | |
| .feat{background:var(--bg);border:1px solid var(--b);border-radius:12px;padding:24px;} | |
| .feat-icon{font-size:1.8rem;margin-bottom:12px;} | |
| .feat-title{font-weight:700;margin-bottom:6px;} | |
| .feat-desc{color:#777;font-size:.88rem;line-height:1.5;} | |
| .price-sec{padding:80px 20px;max-width:480px;margin:0 auto;text-align:center;} | |
| .price-card{background:var(--card);border:2px solid var(--r);border-radius:16px;padding:40px 32px;margin-top:48px;position:relative;overflow:hidden;} | |
| .price-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--r),#ff6b6b);} | |
| .price-amount{font-family:'Bebas Neue',sans-serif;font-size:5rem;color:var(--r);line-height:1;} | |
| .price-period{color:var(--m);font-size:.9rem;margin-bottom:28px;} | |
| .price-feats{list-style:none;text-align:left;margin-bottom:32px;} | |
| .price-feats li{padding:10px 0;border-bottom:1px solid var(--b);font-size:.9rem;display:flex;align-items:center;gap:10px;} | |
| .price-feats li:last-child{border-bottom:none;} | |
| .chk{color:var(--r);} | |
| .faq-sec{padding:80px 20px;max-width:700px;margin:0 auto;} | |
| .faq-item{border-bottom:1px solid var(--b);padding:20px 0;cursor:pointer;} | |
| .faq-q{font-weight:700;display:flex;justify-content:space-between;font-size:.95rem;} | |
| .faq-a{color:#888;font-size:.88rem;line-height:1.6;margin-top:12px;display:none;} | |
| .faq-item.open .faq-a{display:block;} | |
| .faq-arr{transition:transform .2s;color:var(--r);} | |
| .faq-item.open .faq-arr{transform:rotate(180deg);} | |
| .fcta{padding:100px 20px;text-align:center;background:radial-gradient(ellipse 80% 60% at 50% 100%,#2a0000,transparent 70%);} | |
| .fcta h2{font-family:'Bebas Neue',sans-serif;font-size:clamp(2.5rem,7vw,5rem);line-height:1;margin-bottom:24px;} | |
| .fcta h2 span{color:var(--r);} | |
| footer{text-align:center;padding:24px;color:var(--m);font-size:.8rem;border-top:1px solid var(--b);} | |
| @keyframes fd{from{opacity:0;transform:translateY(-20px);}to{opacity:1;transform:translateY(0);}} | |
| </style> | |
| </head> | |
| <body> | |
| <section class="hero"> | |
| <div class="badge">📢 Publicidad Automatica con IA</div> | |
| <h1>TU MARCA<br><span>EN TODAS PARTES</span></h1> | |
| <p class="sub">Genera contenido en video para tu negocio, marca o proyecto <strong style="color:#fff">sin grabar, sin editar y sin contratar a nadie.</strong><br><br>El sistema crea y publica videos solo. Tu presencia crece mientras tu trabajas en lo que importa.</p> | |
| <div class="hero-cta"> | |
| <a href="#planes" class="btn">🚀 QUIERO MI SISTEMA</a> | |
| <span class="price-note">Desde <strong>$49</strong> · Pago unico</span> | |
| </div> | |
| </section> | |
| <div class="sec"> | |
| <div class="sec-label">El problema</div> | |
| <h2>CREAR CONTENIDO<br><span style="color:#ff2d2d">CUESTA CARO</span></h2> | |
| <p style="font-size:1.1rem;color:#aaa;line-height:1.8;margin-bottom:20px;">Estar presente hoy significa publicar video constantemente. Pero grabar, editar y publicar cada dia <strong style="color:#fff">te roba horas</strong> o te obliga a pagar a un editor, una agencia o un community manager.</p> | |
| <p style="font-size:1.1rem;color:#aaa;line-height:1.8;">Mientras tanto, tu competencia publica todos los dias y se queda con la atencion de tu cliente. <strong style="color:#D4AF37">Necesitas estar presente sin que te consuma la vida.</strong></p> | |
| </div> | |
| <div class="sec"> | |
| <div class="sec-label">La solucion</div> | |
| <h2>UN SISTEMA QUE<br>TRABAJA POR TI</h2> | |
| <div style="background:#111;border-radius:15px;padding:30px;margin-top:30px"> | |
| <ul style="list-style:none;padding:0"> | |
| <li style="padding:14px 0;font-size:1.1rem;color:#fff;border-bottom:1px solid #222">✅ Genera guiones con psicologia de retencion</li> | |
| <li style="padding:14px 0;font-size:1.1rem;color:#fff;border-bottom:1px solid #222">✅ Crea voz natural en el acento de tu pais</li> | |
| <li style="padding:14px 0;font-size:1.1rem;color:#fff;border-bottom:1px solid #222">✅ Monta el video con tus imagenes o las suyas</li> | |
| <li style="padding:14px 0;font-size:1.1rem;color:#fff;border-bottom:1px solid #222">✅ Añade subtitulos y tu mensaje de marca</li> | |
| <li style="padding:14px 0;font-size:1.1rem;color:#fff">✅ Publica solo en tu canal cada 4 horas</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <div class="feats"> | |
| <div class="feats-inner"> | |
| <div class="sec-label">A quien sirve</div> | |
| <h2>¿PARA QUIEN ES?</h2> | |
| <div style="background:#0d0d0d;border-radius:15px;padding:25px;margin-top:20px;border:1px solid #1e1e1e"> | |
| <ul style="list-style:none;padding:0"> | |
| <li style="padding:12px 0;color:#fff;font-size:1.05rem;border-bottom:1px solid #222">🏪 Negocios que quieren mas clientes sin gastar en publicidad cara</li> | |
| <li style="padding:12px 0;color:#fff;font-size:1.05rem;border-bottom:1px solid #222">🚀 Emprendedores que venden un producto o servicio</li> | |
| <li style="padding:12px 0;color:#fff;font-size:1.05rem;border-bottom:1px solid #222">🎨 Creadores y marcas que quieren presencia sin grabar</li> | |
| <li style="padding:12px 0;color:#fff;font-size:1.05rem">✍️ Quien quiere estar en todas partes sin perder tiempo</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="sec"> | |
| <div class="sec-label">Como funciona</div> | |
| <h2>EN 4 PASOS</h2> | |
| <div class="steps"> | |
| <div class="step"><div class="step-n">01</div><div><div class="step-title">Eliges tu tema y tu mensaje</div><div class="step-desc">Le dices de que va tu marca o producto y que quieres comunicar.</div></div></div> | |
| <div class="step"><div class="step-n">02</div><div><div class="step-title">El sistema crea el contenido</div><div class="step-desc">Guion con psicologia de venta, voz de tu pais, imagenes y montaje.</div></div></div> | |
| <div class="step"><div class="step-n">03</div><div><div class="step-title">El video se produce solo</div><div class="step-desc">Composicion profesional con tu material o el del sistema.</div></div></div> | |
| <div class="step"><div class="step-n">04</div><div><div class="step-title">Se publica en tu canal</div><div class="step-desc">Tu presencia crece sola, cada dia, sin que toques nada.</div></div></div> | |
| </div> | |
| </div> | |
| <div class="feats"> | |
| <div class="feats-inner"> | |
| <div class="sec-label">¿Por que TubeBot?</div> | |
| <h2>LO QUE TE DA</h2> | |
| <div class="feat-grid"> | |
| <div class="feat"><div class="feat-icon">🎬</div><div class="feat-title">Composicion Profesional</div><div class="feat-desc">Cada imagen se procesa con fondo y composicion cinematografica. Parece hecho por un editor.</div></div> | |
| <div class="feat"><div class="feat-icon">🎙️</div><div class="feat-title">Voz de Tu Pais</div><div class="feat-desc">🇪🇸 🇲🇽 🇦🇷 🇨🇴 🇺🇸 🇬🇧 🇧🇷 Conecta con tu audiencia local.</div></div> | |
| <div class="feat"><div class="feat-icon">📸</div><div class="feat-title">Tu Material o el Suyo</div><div class="feat-desc">Sube tus imagenes y videos, o deja que el sistema los ponga. Tu marca, tu estilo.</div></div> | |
| <div class="feat"><div class="feat-icon">🧠</div><div class="feat-title">Psicologia de Venta</div><div class="feat-desc">Los guiones usan gancho, retencion y llamada a la accion. No solo informan, mueven a actuar.</div></div> | |
| <div class="feat"><div class="feat-icon">🎨</div><div class="feat-title">100% Personalizable</div><div class="feat-desc">Color de subtitulos, frases, link en descripcion, tono. Todo a tu gusto sin tocar codigo.</div></div> | |
| <div class="feat"><div class="feat-icon">🤖</div><div class="feat-title">Totalmente Automatico</div><div class="feat-desc">Una vez activado, publica solo cada 4 horas. Tu presencia crece mientras duermes.</div></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div style="background:#0a2a0a;border-top:2px solid #4caf50;border-bottom:2px solid #4caf50;padding:50px 20px;text-align:center;"> | |
| <div style="font-size:3rem;margin-bottom:10px">🛡️</div> | |
| <h2 style="font-family:Bebas Neue,sans-serif;color:#4caf50;font-size:clamp(2rem,5vw,3.5rem);line-height:1;">GARANTIA DE 7 DIAS</h2> | |
| <p style="color:#aaa;font-size:1.1rem;margin-top:16px;max-width:500px;margin-left:auto;margin-right:auto;">Si el sistema no funciona como prometo, te devuelvo el 100%. Sin preguntas.</p> | |
| </div> | |
| <div class="price-sec" id="planes"> | |
| <div class="sec-label" style="text-align:center;">Planes</div> | |
| <h2 style="font-family:Bebas Neue,sans-serif;font-size:clamp(2.5rem,6vw,4.5rem);line-height:1;text-align:center;margin-bottom:20px;">ELIGE TU PLAN</h2> | |
| <p style="text-align:center;color:#aaa;margin-bottom:10px;">Pago unico. Sin mensualidades. Acceso de por vida.</p> | |
| <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-top:30px;"> | |
| <div style="background:#111;border:1px solid #333;border-radius:14px;padding:28px 20px;display:flex;flex-direction:column"> | |
| <div style="font-weight:700;font-size:1.2rem;margin-bottom:6px">Basico</div> | |
| <div style="font-family:Bebas Neue,sans-serif;font-size:3rem;color:#fff;line-height:1">$49</div> | |
| <div style="color:#666;font-size:.8rem;margin-bottom:18px">Pago unico</div> | |
| <ul style="list-style:none;padding:0;margin-bottom:20px;flex:1"> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ <b>1 canal</b> automatico</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ 1 tema a elegir</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Videos cada 4 horas</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Soporte por email</li> | |
| </ul> | |
| <a href="https://buy.stripe.com/00w7sL8J84QQ2ZdeGUdEs02" style="background:#333;color:#fff;padding:14px;border-radius:8px;text-decoration:none;text-align:center;font-weight:700;display:block">ELEGIR BASICO</a> | |
| </div> | |
| <div style="background:#111;border:2px solid #D4AF37;border-radius:14px;padding:28px 20px;display:flex;flex-direction:column;position:relative"> | |
| <div style="position:absolute;top:-12px;left:50%;transform:translateX(-50%);background:#D4AF37;color:#000;font-size:.7rem;font-weight:900;padding:4px 14px;border-radius:50px">POPULAR</div> | |
| <div style="font-weight:700;font-size:1.2rem;margin-bottom:6px">🥇 Oro</div> | |
| <div style="font-family:Bebas Neue,sans-serif;font-size:3rem;color:#D4AF37;line-height:1">$147</div> | |
| <div style="color:#666;font-size:.8rem;margin-bottom:18px">Pago unico</div> | |
| <ul style="list-style:none;padding:0;margin-bottom:20px;flex:1"> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ <b>5 canales</b> automaticos</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ 5 temas diferentes</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Videos cada 4 horas</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Soporte WhatsApp prioritario</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Cambio de tema gratis</li> | |
| </ul> | |
| <a href="https://buy.stripe.com/00w00j9Ncdnm7ftcyMdEs03" style="background:#D4AF37;color:#000;padding:14px;border-radius:8px;text-decoration:none;text-align:center;font-weight:900;display:block">ELEGIR ORO</a> | |
| </div> | |
| <div style="background:#111;border:1px solid #00bfff;border-radius:14px;padding:28px 20px;display:flex;flex-direction:column"> | |
| <div style="font-weight:700;font-size:1.2rem;margin-bottom:6px">💎 Diamante</div> | |
| <div style="font-family:Bebas Neue,sans-serif;font-size:3rem;color:#00bfff;line-height:1">$297</div> | |
| <div style="color:#666;font-size:.8rem;margin-bottom:18px">Pago unico</div> | |
| <ul style="list-style:none;padding:0;margin-bottom:20px;flex:1"> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ <b>9 canales</b> automaticos</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Todos los temas</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Videos cada 4 horas</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Soporte VIP 24/7</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Temas premium exclusivos</li> | |
| </ul> | |
| <a href="https://buy.stripe.com/fZucN5e3s2II43h2YcdEs04" style="background:#00bfff;color:#000;padding:14px;border-radius:8px;text-decoration:none;text-align:center;font-weight:900;display:block">ELEGIR DIAMANTE</a> | |
| </div> | |
| <div style="background:#111;border:1px solid #b300ff;border-radius:14px;padding:28px 20px;display:flex;flex-direction:column"> | |
| <div style="font-weight:700;font-size:1.2rem;margin-bottom:6px">🏆 Platinum</div> | |
| <div style="font-family:Bebas Neue,sans-serif;font-size:3rem;color:#b300ff;line-height:1">$497</div> | |
| <div style="color:#666;font-size:.8rem;margin-bottom:18px">Pago unico</div> | |
| <ul style="list-style:none;padding:0;margin-bottom:20px;flex:1"> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ <b>20 canales</b> automaticos</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Todos los temas + futuros</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Videos cada 4 horas</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ WhatsApp directo conmigo</li> | |
| <li style="padding:8px 0;color:#fff;font-size:.95rem">✅ Funciones nuevas antes que nadie</li> | |
| </ul> | |
| <a href="https://buy.stripe.com/5kQ28r1gG1EEdDRgP2dEs06" style="background:#b300ff;color:#fff;padding:14px;border-radius:8px;text-decoration:none;text-align:center;font-weight:900;display:block">ELEGIR PLATINUM</a> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="faq-sec"> | |
| <div class="sec-label" style="text-align:center;">FAQ</div> | |
| <h2 style="font-family:Bebas Neue,sans-serif;font-size:clamp(2.5rem,6vw,4.5rem);line-height:1;text-align:center;margin-bottom:32px;">PREGUNTAS FRECUENTES</h2> | |
| <div class="faq-item" onclick="this.classList.toggle('open')"><div class="faq-q">¿Necesito mostrar mi cara? <span class="faq-arr">▼</span></div><div class="faq-a">No. El sistema crea todo el contenido sin que aparezcas.</div></div> | |
| <div class="faq-item" onclick="this.classList.toggle('open')"><div class="faq-q">¿Puedo usar mis propias imagenes y mi marca? <span class="faq-arr">▼</span></div><div class="faq-a">Si. Puedes subir tu material o dejar que el sistema lo ponga. Tu mensaje y tu identidad.</div></div> | |
| <div class="faq-item" onclick="this.classList.toggle('open')"><div class="faq-q">¿Necesito experiencia tecnica? <span class="faq-arr">▼</span></div><div class="faq-a">No. Eliges tu tema y el sistema hace el resto solo.</div></div> | |
| <div class="faq-item" onclick="this.classList.toggle('open')"><div class="faq-q">¿Sirve para promocionar mi negocio? <span class="faq-arr">▼</span></div><div class="faq-a">Si. Esa es justo su fuerza: presencia y publicidad automatica para tu marca o producto.</div></div> | |
| <div class="faq-item" onclick="this.classList.toggle('open')"><div class="faq-q">¿Hay pagos mensuales? <span class="faq-arr">▼</span></div><div class="faq-a">No. Es un pago unico con acceso de por vida.</div></div> | |
| </div> | |
| <div class="fcta"> | |
| <h2>TU MARCA<br><span>NO PUEDE ESPERAR</span></h2> | |
| <p style="color:#aaa;max-width:500px;margin:0 auto 32px;line-height:1.6;">Cada dia sin publicar es atencion que se lleva otro. Activa tu sistema y empieza a estar presente hoy.</p> | |
| <a href="#planes" class="btn">🚀 QUIERO MI SISTEMA</a> | |
| </div> | |
| <footer>© 2026 TubeBot · Todos los derechos reservados<br>💬 Soporte: +34 656 691 085</footer> | |
| </body> | |
| </html>""" | |
| # ══════════════════════════════════════════ | |
| # PÁGINA DE ACTIVACIÓN — DESPUÉS DEL PAGO | |
| # ══════════════════════════════════════════ | |
| ACTIVAR_HTML = """<!DOCTYPE html> | |
| <html lang="es"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>TubeBot — Activa tu canal</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Manrope:wght@400;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| *{box-sizing:border-box;margin:0;padding:0;} | |
| body{background:#080808;color:#f0f0f0;font-family:'Manrope',sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;} | |
| .card{background:#111;border:1px solid #222;border-radius:20px;padding:40px 32px;max-width:480px;width:100%;text-align:center;} | |
| .check-icon{font-size:3rem;margin-bottom:16px;} | |
| h1{font-family:'Bebas Neue',sans-serif;font-size:2.5rem;color:#ff2d2d;margin-bottom:8px;} | |
| .sub{color:#888;font-size:.95rem;margin-bottom:32px;line-height:1.6;} | |
| label{display:block;text-align:left;font-size:.75rem;letter-spacing:2px;text-transform:uppercase;color:#ff2d2d;margin-bottom:6px;margin-top:16px;} | |
| input,select{width:100%;background:#0a0a0a;border:1px solid #222;color:#f0f0f0;padding:14px 16px;border-radius:10px;font-family:'Manrope',sans-serif;font-size:.95rem;outline:none;} | |
| input:focus,select:focus{border-color:#ff2d2d;} | |
| .btn{width:100%;background:#ff2d2d;color:#fff;border:none;padding:16px;border-radius:10px;font-family:'Manrope',sans-serif;font-size:1rem;font-weight:800;cursor:pointer;margin-top:24px;transition:all .2s;} | |
| .btn:hover{background:#cc0000;} | |
| .btn:disabled{opacity:.5;cursor:not-allowed;} | |
| .loading{display:none;margin-top:20px;color:#ff2d2d;font-size:.9rem;line-height:1.6;} | |
| .result{display:none;margin-top:24px;background:#0a0a0a;border:1px solid #ff2d2d;border-radius:12px;padding:24px;text-align:left;} | |
| .result h3{color:#ff2d2d;margin-bottom:12px;font-size:1.1rem;} | |
| .result p{color:#888;font-size:.85rem;margin-bottom:8px;line-height:1.5;} | |
| .url-box{background:#111;border:1px solid #333;border-radius:8px;padding:12px;word-break:break-all;font-size:.85rem;margin:12px 0;} | |
| .url-box a{color:#fff;text-decoration:none;} | |
| .copy-btn{background:transparent;border:1px solid #ff2d2d;color:#ff2d2d;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:.8rem;font-family:'Manrope',sans-serif;font-weight:700;width:100%;margin-top:8px;transition:all .2s;} | |
| .copy-btn:hover{background:#ff2d2d;color:#fff;} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <div class="check-icon">✅</div> | |
| <h1>¡PAGO CONFIRMADO!</h1> | |
| <p class="sub">Plan: <b style="color:#D4AF37;text-transform:uppercase">PLAN_PLACEHOLDER</b> · Puedes crear <b style="color:#ff2d2d">CANALES_PLACEHOLDER</b> canal(es)</p> | |
| <label>Tu email</label> | |
| <input type="email" id="email" placeholder="tu@email.com"/> | |
| <div id="canales-container"></div> | |
| <script> | |
| const numCanales = CANALES_PLACEHOLDER; | |
| const nichos = [ | |
| {v:"fitness y salud",t:"💪 Fitness y Salud"}, | |
| {v:"finanzas personales",t:"💰 Finanzas Personales"}, | |
| {v:"criptomonedas",t:"₿ Criptomonedas"}, | |
| {v:"tecnologia e inteligencia artificial",t:"🤖 Tecnología e IA"}, | |
| {v:"motivacion y exito personal",t:"🔥 Motivación y Éxito"}, | |
| {v:"mascotas",t:"🐾 Mascotas"}, | |
| {v:"recetas de cocina",t:"🍳 Cocina y Recetas"}, | |
| {v:"viajes y aventura",t:"✈️ Viajes"}, | |
| {v:"emprendimiento y negocios",t:"🚀 Emprendimiento"} | |
| ]; | |
| const maxCanales = 1; | |
| const container = document.getElementById("canales-container"); | |
| for(let i=1; i<=maxCanales; i++){ | |
| const div = document.createElement("div"); | |
| div.style.cssText="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:10px;padding:18px;margin-bottom:14px"; | |
| div.innerHTML = ` | |
| <h4 style="color:#D4AF37;margin:0 0 12px;font-size:1rem">🎯 Configura tu canal</h4> | |
| <label style="font-size:.85rem;color:#aaa">Nicho de tu canal</label> | |
| <input type="text" id="nicho${i}" placeholder="Ej: entrevistas en la calle, promocionar canal de YouTube, motivacion" list="nichos-sug" style="margin-bottom:8px;width:100%;padding:10px;background:#080808;color:#fff;border:1px solid #333;border-radius:8px"/> | |
| <datalist id="nichos-sug">${nichos.map(n=>`<option value="${n.v}">`).join("")}</datalist> | |
| <label style="font-size:.85rem;color:#aaa">Tipo de video</label> | |
| <select id="tipo${i}"> | |
| <option value="largo">📺 Video Largo</option> | |
| <option value="short">📱 Short Vertical</option> | |
| </select> | |
| <label style="font-size:.85rem;color:#aaa;margin-top:12px;display:block">✨ ¿Tienes alguna preferencia? <span style="color:#666">(opcional)</span></label> | |
| <textarea id="config${i}" rows="4" placeholder="Ej: voz femenina en ingles, subtitulos grandes arriba, tono motivacional, sin musica..." style="width:100%;padding:12px;background:#080808;color:#fff;border:1px solid #333;border-radius:8px;resize:vertical;font-family:system-ui;margin-top:6px"></textarea> | |
| <label style="font-size:.85rem;color:#aaa;margin-top:12px;display:block">📸 Sube tus imagenes o videos <span style="color:#666">(opcional, max 20)</span></label> | |
| <input type="file" id="media${i}" multiple accept="image/*,video/*" style="margin-top:6px;width:100%;padding:10px;background:#080808;color:#fff;border:1px solid #333;border-radius:8px"/> | |
| <div id="mediainfo${i}" style="color:#888;font-size:.8rem;margin-top:4px"></div> | |
| `; | |
| container.appendChild(div); | |
| } | |
| </script> | |
| <button class="btn" id="btn" onclick="activar()">⚡ CREAR MI CANAL AUTOMÁTICO</button> | |
| <div class="loading" id="loading"> | |
| ⚡ Creando tu sistema en Hugging Face...<br> | |
| <small style="color:#666;">Esto puede tardar 1-2 minutos</small> | |
| </div> | |
| <div class="result" id="result"> | |
| <h3>🎉 ¡Tu canal está listo!</h3> | |
| <p>Accede aquí para ver tu sistema funcionando en tiempo real:</p> | |
| <div class="url-box"><a id="url-link" href="#" target="_blank">Cargando...</a></div> | |
| <button class="copy-btn" onclick="copiar()">📋 Copiar enlace</button> | |
| <a id="yt-btn" href="#" target="_blank" class="btn" style="display:block;margin-top:12px;background:#ff0000;text-align:center;text-decoration:none;">📺 Conectar mi canal de YouTube</a> | |
| <div style="margin-top:20px;background:#0d0d0d;border:1px solid #333;border-radius:12px;padding:20px;text-align:left;"> | |
| <p style="color:#ff2d2d;font-weight:700;margin-bottom:12px;">📋 GUÍA PASO A PASO:</p> | |
| <p style="color:#888;font-size:.85rem;line-height:2;"> | |
| 1️⃣ <b style="color:#fff;">Entra a tu URL</b> — Si pide login pulsa "Sign in as guest" o "Opciones avanzadas" para entrar sin cuenta<br> | |
| 2️⃣ <b style="color:#fff;">Espera 2-3 minutos</b> — La primera vez tarda en arrancar, es normal<br> | |
| 3️⃣ <b style="color:#fff;">Verás los logs</b> — Pantalla negra con texto dorado mostrando el bot en tiempo real<br> | |
| 4️⃣ <b style="color:#fff;">El bot trabaja solo</b> — Genera guión, voz, vídeo y lo sube a YouTube cada 4 horas<br> | |
| 5️⃣ <b style="color:#fff;">Sin tocar nada</b> — Funciona 24/7 completamente solo<br> | |
| 6️⃣ <b style="color:#fff;">¿Problemas?</b> — Escríbeme y te ayudo en minutos | |
| </p> | |
| <a href="https://wa.me/34656691085?text=Hola,%20necesito%20ayuda%20con%20mi%20TubeBot" style="display:block;margin-top:16px;background:#25d366;color:#fff;padding:12px;border-radius:8px;text-align:center;text-decoration:none;font-weight:700;">💬 Soporte WhatsApp</a> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| async function activar(){ | |
| const email = document.getElementById('email').value.trim(); | |
| const numCanales = parseInt("CANALES_PLACEHOLDER") || 1; | |
| const canales = []; | |
| for(let i=1; i<=numCanales; i++){ | |
| const n = document.getElementById('nicho'+i); | |
| const t = document.getElementById('tipo'+i); | |
| const conf = document.getElementById('config'+i); | |
| if(n && t) canales.push({nicho: n.value, tipo: t.value, config: conf ? conf.value : ""}); | |
| } | |
| // Recoger archivos y convertir a base64 | |
| const archivos = []; | |
| const fileInput = document.getElementById('media1'); | |
| if (fileInput && fileInput.files && fileInput.files.length > 0) { | |
| document.getElementById('mediainfo1').textContent = "Procesando archivos..."; | |
| const files = Array.from(fileInput.files).slice(0, 20); | |
| for (const f of files) { | |
| try { | |
| // Comprimir imagen antes de subir | |
| const b64 = await new Promise((resolve, reject) => { | |
| if (!f.type.startsWith('image/')) { | |
| const r = new FileReader(); | |
| r.onload = () => resolve(r.result.split(',')[1]); | |
| r.onerror = reject; | |
| r.readAsDataURL(f); | |
| return; | |
| } | |
| const img = new Image(); | |
| const url = URL.createObjectURL(f); | |
| img.onload = () => { | |
| const maxW = 1080; | |
| let w = img.width, h = img.height; | |
| if (w > maxW) { h = h * (maxW/w); w = maxW; } | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = w; canvas.height = h; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(img, 0, 0, w, h); | |
| const dataUrl = canvas.toDataURL('image/jpeg', 0.8); | |
| URL.revokeObjectURL(url); | |
| resolve(dataUrl.split(',')[1]); | |
| }; | |
| img.onerror = reject; | |
| img.src = url; | |
| }); | |
| archivos.push({nombre: f.name.replace(/\.[^.]+$/, '.jpg'), contenido: b64}); | |
| } catch(e) { console.log("Error archivo", f.name, e); } | |
| } | |
| document.getElementById('mediainfo1').textContent = archivos.length + " archivos listos"; | |
| } | |
| document.getElementById('btn').disabled = true; | |
| document.getElementById('loading').style.display = 'block'; | |
| try{ | |
| const ctrl = new AbortController(); | |
| setTimeout(() => ctrl.abort(), 300000); | |
| const r = await fetch('/crear', { | |
| method:'POST', | |
| signal: ctrl.signal, | |
| headers:{'Content-Type':'application/json'}, | |
| body: JSON.stringify({email, canales, archivos, token: "TOKEN_PLACEHOLDER"}) | |
| }); | |
| const d = await r.json(); | |
| if(d.url){ | |
| const emailVal = document.getElementById("email").value.trim(); | |
| window.location.href = "/mi-cuenta?email=" + encodeURIComponent(emailVal); | |
| } else { | |
| document.getElementById('btn').disabled = false; | |
| } | |
| }catch(e){ | |
| document.getElementById('btn').disabled = false; | |
| } | |
| document.getElementById('loading').style.display = 'none'; | |
| } | |
| function copiar(){ | |
| } | |
| </script> | |
| </body> | |
| </html>""" | |
| # ══════════════════════════════════════════ | |
| # RUTAS | |
| # ══════════════════════════════════════════ | |
| VISITAS_CACHE = {} # IP -> timestamp para no spamear avisos | |
| async def home(request: Request): | |
| try: | |
| ip = request.client.host if request.client else "?" | |
| import time as _t | |
| ahora = _t.time() | |
| # Avisar solo si esa IP no visito en los ultimos 10 min | |
| if ip not in VISITAS_CACHE or ahora - VISITAS_CACHE[ip] > 600: | |
| VISITAS_CACHE[ip] = ahora | |
| ua = request.headers.get("user-agent","?")[:80] | |
| # Filtrar bots HF y crawlers | |
| ignorar = ["HuggingFace", "bot", "spider", "crawler", "python-requests", "curl"] | |
| if any(b.lower() in ua.lower() for b in ignorar): | |
| pass # No avisar | |
| else: | |
| referer = request.headers.get("referer", "directo/sin referer") | |
| avisar_telegram(f"👤 <b>Visita a landing</b>\n🌐 IP: {ip}\n📱 {ua}\n🔗 Viene de: {referer}") | |
| except: pass | |
| return VENTAS_HTML | |
| PLANES_CANALES = {"basico": 1, "oro": 5, "diamante": 9, "platinum": 20} | |
| async def activar_canal(token: str = "", plan: str = ""): | |
| if plan and plan in PLANES_CANALES: | |
| import secrets | |
| nuevo_token = secrets.token_urlsafe(32) | |
| TOKENS_VALIDOS[nuevo_token] = {"plan": plan} | |
| avisar_telegram(f"💰 <b>POSIBLE VENTA</b>\n📦 Plan: {plan.upper()}\n🔥 Cliente llego a activar-canal tras pago Stripe") | |
| return ACTIVAR_HTML.replace("PLAN_PLACEHOLDER", plan).replace("TOKEN_PLACEHOLDER", nuevo_token).replace("CANALES_PLACEHOLDER", str(PLANES_CANALES[plan])) | |
| if token and token in TOKENS_VALIDOS: | |
| plan_token = TOKENS_VALIDOS[token].get("plan", "basico") if isinstance(TOKENS_VALIDOS[token], dict) else "basico" | |
| return ACTIVAR_HTML.replace("PLAN_PLACEHOLDER", plan_token).replace("TOKEN_PLACEHOLDER", token).replace("CANALES_PLACEHOLDER", str(PLANES_CANALES.get(plan_token, 1))) | |
| return HTMLResponse("<html><body style='background:#080808;color:#fff;text-align:center;padding:80px'><h1 style='color:#ff2d2d'>Acceso no autorizado</h1><a href='/' style='color:#ff2d2d'>Volver</a></body></html>", status_code=403) | |
| async def parsear_config_groq(texto): | |
| """Parsea instrucciones del cliente con Groq a JSON de configuracion""" | |
| if not texto or not texto.strip(): | |
| return {} | |
| try: | |
| import httpx | |
| prompt = f"""Analiza las preferencias del cliente y devuelve SOLO un JSON valido con estas keys (omite las que no se mencionen): | |
| - voz_ia: true/false (si quiere voz IA, default true si no dice nada) | |
| - voz_genero: "masculina" o "femenina" (default masculina) | |
| - pais_voz: pais del acento de voz si lo menciona el cliente. Valores: "españa", "mexico", "argentina", "colombia", "usa", "uk", "australia", "brasil", "francia". Si no menciona pais, dejar vacio | |
| - idioma: "es"/"en"/"pt"/"fr" (default es) | |
| - velocidad_voz: 0.8 a 1.5 (default 1.0) | |
| - subir_musica: true/false (default false) | |
| - subir_voz: true/false (default false) | |
| - tono: "serio"/"divertido"/"motivacional"/"educativo" (default educativo) | |
| - subtitulos: true/false (default true) | |
| - estilo_subtitulos: "tiktok"/"clasico"/"minimal" (default tiktok) | |
| - color_subtitulos: codigo hex como "#D4AF37" (default #D4AF37) | |
| - posicion_subtitulos: "arriba"/"centro"/"abajo" (default abajo) | |
| - frase_inicio: texto literal que el cliente quiere al inicio del video (string vacio si no menciona) | |
| - frase_final: texto literal que el cliente quiere al final del video (string vacio si no menciona) | |
| - canal_promocionar: URL del canal que quiere promocionar (string vacio si no hay) | |
| - link_descripcion: URL/link a poner en descripcion del video (string vacio si no hay) | |
| Preferencias del cliente: {texto} | |
| Responde SOLO el JSON, sin explicaciones, sin markdown.""" | |
| async with httpx.AsyncClient(timeout=20) as cli: | |
| r = await cli.post("https://api.groq.com/openai/v1/chat/completions", | |
| headers={"Authorization": f"Bearer {os.environ.get('GROQ_KEY','')}"}, | |
| json={"model":"llama-3.3-70b-versatile","messages":[{"role":"user","content":prompt}],"temperature":0.2} | |
| ) | |
| txt = r.json()["choices"][0]["message"]["content"].strip() | |
| # Limpiar markdown si lo trae | |
| if "```" in txt: | |
| txt = txt.split("```")[1].replace("json","",1).strip() | |
| import json | |
| return json.loads(txt) | |
| except Exception as e: | |
| print(f"Error parseando config: {e}", flush=True) | |
| return {} | |
| async def crear(request: Request): | |
| data = await request.json() | |
| email = data.get("email","").strip().lower() | |
| canales_data = data.get("canales", []) | |
| archivos_subidos = data.get("archivos", []) | |
| if canales_data: | |
| nicho = canales_data[0].get("nicho","tecnologia") | |
| tipo = canales_data[0].get("tipo","largo") | |
| config_libre = canales_data[0].get("config","").strip() | |
| else: | |
| nicho = data.get("nicho","tecnologia") | |
| tipo = data.get("tipo","largo") | |
| config_libre = "" | |
| token = data.get("token","") | |
| # Verificar token o sesion existente | |
| plan = "basico" | |
| if token and token in TOKENS_VALIDOS: | |
| plan = TOKENS_VALIDOS[token].get("plan", "basico") if isinstance(TOKENS_VALIDOS[token], dict) else "basico" | |
| elif email in CLIENTES and CLIENTES[email].get("plan"): | |
| plan = CLIENTES[email]["plan"] | |
| else: | |
| return JSONResponse({"error": "Token invalido"}, status_code=403) | |
| # Verificar limite del plan | |
| limite = PLANES_CANALES.get(plan, 1) | |
| canales_actuales = len(CLIENTES.get(email, {}).get("canales", [])) | |
| if canales_actuales >= limite: | |
| return JSONResponse({"error": f"Has alcanzado el limite de tu plan {plan} ({limite} canales)"}, status_code=400) | |
| # Parsear preferencias del cliente con Groq ANTES de crear | |
| config_parsed = await parsear_config_groq(config_libre) | |
| # Asignar proyecto disponible y guardarlo en config | |
| if not isinstance(config_parsed, dict): | |
| config_parsed = {} | |
| idx_disponible = proyecto_disponible() | |
| config_parsed["proyecto_idx"] = idx_disponible | |
| # Crear el canal pasando la config | |
| resultado = await crear_space(nicho, email, tipo, config_parsed, archivos_subidos) | |
| if not resultado: | |
| return JSONResponse({"error": "No se pudo crear el canal"}, status_code=500) | |
| u = resultado["url"] | |
| repo = resultado["repo"] | |
| # Guardar en CLIENTES | |
| if email not in CLIENTES: | |
| CLIENTES[email] = {"canales": [], "plan": plan} | |
| CLIENTES[email]["plan"] = plan | |
| if "canales" not in CLIENTES[email]: | |
| CLIENTES[email]["canales"] = [] | |
| CLIENTES[email]["canales"].append({"url": u, "nicho": nicho, "tipo": tipo, "repo": repo, "youtube": False, "config": config_parsed, "config_texto": config_libre}) | |
| guardar_clientes() | |
| avisar_telegram(f"🎬 <b>Canal creado</b>\n📧 {email}\n📺 Plan: {plan}\n🎯 Nicho: {nicho}\n🔗 {u}") | |
| # Consumir token si era nuevo | |
| if token in TOKENS_VALIDOS: | |
| del TOKENS_VALIDOS[token] | |
| urls = [c["url"] for c in CLIENTES[email]["canales"]] | |
| try: | |
| enviar_email_multi(email, urls) | |
| except Exception as e: | |
| print(f"Error email: {e}", flush=True) | |
| return {"success": True, "url": u, "total": len(urls), "limite": limite} | |
| async def editar_config(request: Request): | |
| data = await request.json() | |
| email = data.get("email","").strip().lower() | |
| canal_idx = data.get("canal_idx", -1) | |
| nuevo_texto = data.get("config_texto","").strip() | |
| if email not in CLIENTES: | |
| return JSONResponse({"error":"Cuenta no encontrada"}, status_code=404) | |
| canales = CLIENTES[email].get("canales", []) | |
| if canal_idx < 0 or canal_idx >= len(canales): | |
| return JSONResponse({"error":"Canal no encontrado"}, status_code=404) | |
| canal = canales[canal_idx] | |
| repo = canal.get("repo","") | |
| nicho = canal.get("nicho","tecnologia") | |
| tipo = canal.get("tipo","largo") | |
| token_hf = token_de_repo(repo) | |
| if not token_hf: | |
| return JSONResponse({"error":"No se pudo acceder al canal"}, status_code=500) | |
| # Re-parsear la nueva config con Groq | |
| config_parsed = await parsear_config_groq(nuevo_texto) | |
| if not isinstance(config_parsed, dict): | |
| config_parsed = {} | |
| # Conservar el proyecto google asignado | |
| if "proyecto_idx" in canal.get("config", {}): | |
| config_parsed["proyecto_idx"] = canal["config"]["proyecto_idx"] | |
| # Regenerar el main.py del canal con la nueva config | |
| import base64, json as _json | |
| def b64(s): return base64.b64encode(s.encode("utf-8")).decode("utf-8") | |
| with open("cliente_template.py","r") as _f: | |
| main_code = _f.read().replace("NICHO_PLACEHOLDER", nicho).replace("EMAIL_PLACEHOLDER", email).replace("REPO_PLACEHOLDER", repo).replace("GROQ_PLACEHOLDER", os.environ.get("GROQ_KEY","")).replace("GROQ_PLACEHOLDER_2", os.environ.get("GROQ_KEY_2","")).replace("GROQ_PLACEHOLDER_3", os.environ.get("GROQ_KEY_3","")).replace("PEXELS_PLACEHOLDER", os.environ.get("PEXELS_KEY","")).replace("TIPO_PLACEHOLDER", tipo).replace("CONFIG_PLACEHOLDER", _json.dumps(config_parsed)) | |
| commit_payload = { | |
| "summary": "Editar config", | |
| "files": [{"path":"main.py","content":b64(main_code),"encoding":"base64"}] | |
| } | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| r = await client.post( | |
| f"https://huggingface.co/api/spaces/{repo}/commit/main", | |
| headers={"Authorization": f"Bearer {token_hf}", "Content-Type":"application/json"}, | |
| content=_json.dumps(commit_payload).encode() | |
| ) | |
| if r.status_code not in [200,201]: | |
| return JSONResponse({"error":"No se pudo aplicar"}, status_code=500) | |
| # Guardar la nueva config en CLIENTES | |
| CLIENTES[email]["canales"][canal_idx]["config"] = config_parsed | |
| CLIENTES[email]["canales"][canal_idx]["config_texto"] = nuevo_texto | |
| guardar_clientes() | |
| return {"success": True} | |
| async def mi_cuenta(email: str = ""): | |
| email = email.strip().lower() | |
| if not email or email not in CLIENTES: | |
| return HTMLResponse("<html><body style='background:#080808;color:#fff;text-align:center;padding:60px;font-family:sans-serif'><h1 style='color:#ff2d2d'>Cuenta no encontrada</h1><p>Verifica el email correcto.</p></body></html>", status_code=404) | |
| cliente = CLIENTES[email] | |
| plan = cliente.get("plan", "basico") | |
| limite = PLANES_CANALES.get(plan, 1) | |
| canales = cliente.get("canales", []) | |
| restantes = limite - len(canales) | |
| lista_html = "" | |
| for i, ch in enumerate(canales, 1): | |
| ch_url = ch.get("url","#") | |
| yt_conectado = ch.get("youtube", False) | |
| if yt_conectado: | |
| yt_btn = '<div style="margin-top:10px;color:#4caf50;font-size:.9rem">YouTube conectado</div>' | |
| else: | |
| yt_btn = f'<a href="/oauth-youtube?email={email}&canal={i-1}" style="display:inline-block;background:#ff0000;color:#fff;padding:10px 16px;border-radius:6px;text-decoration:none;font-weight:700;margin-top:10px;font-size:.9rem">Conectar YouTube</a>' | |
| cfg_texto = ch.get("config_texto","").replace('"','"').replace("\n"," ") | |
| lista_html += f'''<div style="background:#111;border:1px solid #222;border-radius:10px;padding:18px;margin:10px 0"><div style="color:#D4AF37;font-weight:700;margin-bottom:8px">Canal {i}: {ch.get("nicho","-")}</div><a href="{ch_url}" target="_blank" style="color:#ff2d2d;word-break:break-all;font-size:.9rem">{ch_url}</a>{yt_btn}<div style="margin-top:10px"><button onclick="toggleEdit({i-1})" style="background:#222;color:#fff;border:1px solid #444;padding:8px 14px;border-radius:6px;font-size:.85rem;cursor:pointer">⚙️ Editar config</button></div><div id="edit{i-1}" style="display:none;margin-top:12px"><textarea id="cfg{i-1}" style="width:100%;background:#0a0a0a;color:#fff;border:1px solid #333;border-radius:8px;padding:10px;font-size:.9rem;min-height:80px" placeholder="Ej: voz femenina de Mexico, tono motivador, frase inicio Hola amigos">{cfg_texto}</textarea><button onclick="guardarCfg({i-1})" style="background:#4caf50;color:#fff;border:none;padding:10px 16px;border-radius:6px;font-weight:700;margin-top:8px;cursor:pointer">Guardar cambios</button><span id="msg{i-1}" style="margin-left:10px;font-size:.85rem"></span></div></div>''' | |
| if not lista_html: | |
| lista_html = '<p style="color:#888;text-align:center;padding:20px">Aun no has creado ningun canal</p>' | |
| boton_nuevo = "" | |
| if restantes > 0: | |
| boton_nuevo = f'<a href="/activar-canal?token=micuenta_{email}" style="display:block;background:#ff2d2d;color:#fff;padding:18px;border-radius:10px;text-decoration:none;text-align:center;font-weight:900;margin:30px 0">+ CREAR NUEVO CANAL</a>' | |
| TOKENS_VALIDOS[f"micuenta_{email}"] = {"plan": plan} | |
| else: | |
| boton_nuevo = '<p style="text-align:center;color:#D4AF37;padding:20px;background:#1a1400;border-radius:10px">Has alcanzado el limite de tu plan. Contacta soporte para subir de nivel.</p>' | |
| return HTMLResponse(f"""<!DOCTYPE html> | |
| <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Mi Cuenta - TubeBot</title> | |
| <style>body{{background:#080808;color:#fff;font-family:system-ui,sans-serif;margin:0;padding:30px 20px;max-width:700px;margin:0 auto}}h1{{color:#ff2d2d;font-size:2rem;margin:0 0 10px}}.plan{{display:inline-block;background:#D4AF37;color:#000;padding:4px 14px;border-radius:20px;font-weight:900;text-transform:uppercase;font-size:.85rem}}.stats{{background:#111;border:1px solid #222;border-radius:12px;padding:20px;margin:20px 0}}.stat-num{{font-size:2.5rem;color:#ff2d2d;font-weight:900}}.stat-lbl{{color:#888;font-size:.9rem}}</style></head> | |
| <body> | |
| <h1>🤖 Mi Cuenta TubeBot</h1> | |
| <p style="color:#888;margin:0 0 16px">{email}</p> | |
| <span class="plan">Plan {plan}</span> | |
| <div class="stats"> | |
| <div style="display:flex;justify-content:space-between;text-align:center"> | |
| <div><div class="stat-num">{len(canales)}</div><div class="stat-lbl">Canales creados</div></div> | |
| <div><div class="stat-num">{restantes}</div><div class="stat-lbl">Disponibles</div></div> | |
| <div><div class="stat-num">{limite}</div><div class="stat-lbl">Limite plan</div></div> | |
| </div> | |
| </div> | |
| {boton_nuevo} | |
| <h2 style="font-size:1.3rem;margin-top:30px;color:#fff">Mis canales</h2> | |
| {lista_html} | |
| <p style="text-align:center;color:#666;margin-top:40px;font-size:.85rem">WhatsApp soporte: +34 656 691 085</p> | |
| <script> | |
| function toggleEdit(i){{var e=document.getElementById("edit"+i);e.style.display=e.style.display==="none"?"block":"none";}} | |
| async function guardarCfg(i){{ | |
| var txt=document.getElementById("cfg"+i).value; | |
| var msg=document.getElementById("msg"+i); | |
| msg.textContent="Guardando...";msg.style.color="#D4AF37"; | |
| try{{ | |
| var r=await fetch("/editar-config",{{method:"POST",headers:{{"Content-Type":"application/json"}},body:JSON.stringify({{email:"{email}",canal_idx:i,config_texto:txt}})}}); | |
| var d=await r.json(); | |
| if(d.success){{msg.textContent="✅ Guardado. Se aplica en los proximos videos";msg.style.color="#4caf50";}} | |
| else{{msg.textContent="❌ "+(d.error||"Error");msg.style.color="#ff2d2d";}} | |
| }}catch(e){{msg.textContent="❌ Error de conexion";msg.style.color="#ff2d2d";}} | |
| }} | |
| </script> | |
| </body></html>""") | |
| def enviar_email_multi(email, urls): | |
| try: | |
| import requests | |
| lista_urls = "".join([f'<li style="margin:8px 0"><a href="{u}" style="color:#ff2d2d">{u}</a></li>' for u in urls]) | |
| url_cuenta = f"https://cristobal299-iaweb.hf.space/mi-cuenta?email={email}" | |
| html = f""" | |
| <div style="background:#0a0a0a;color:#fff;padding:40px;font-family:sans-serif;max-width:600px;margin:0 auto;"> | |
| <h1 style="color:#ff2d2d;">TubeBot activado</h1> | |
| <p>Tus <strong>{len(urls)} canales</strong> ya estan funcionando.</p> | |
| <ul style="list-style:none;padding:0;">{lista_urls}</ul> | |
| <p style="margin:30px 0;text-align:center;"> | |
| <a href="{url_cuenta}" style="background:#ff2d2d;color:#fff;padding:14px 28px;border-radius:6px;text-decoration:none;font-weight:bold;display:inline-block">Ir a Mi Cuenta</a> | |
| </p> | |
| <p style="color:#888;font-size:.85rem;text-align:center">Guarda este enlace para gestionar tus canales:<br><code style="color:#D4AF37;font-size:.8rem">{url_cuenta}</code></p> | |
| <p style="color:#666;font-size:.85rem;margin-top:20px">Si tienes dudas responde este email.</p> | |
| </div>""" | |
| requests.post("https://api.brevo.com/v3/smtp/email", | |
| headers={"api-key": os.environ.get("BREVO_KEY",""), "Content-Type": "application/json", "accept": "application/json"}, | |
| json={ | |
| "sender": {"name": "TubeBot", "email": "dayron100813@gmail.com"}, | |
| "to": [{"email": email}], | |
| "subject": f"Tus {len(urls)} canales estan listos", | |
| "htmlContent": html | |
| } | |
| ) | |
| except Exception as e: | |
| print(f"Error email: {e}", flush=True) | |
| async def check_space(url: str = ""): | |
| try: | |
| async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: | |
| r = await client.get(url) | |
| if r.status_code == 200 and "gradio" in r.text.lower(): | |
| return {"ready": True} | |
| except: | |
| pass | |
| return {"ready": False} | |
| async def check_space(url: str = ""): | |
| try: | |
| async with httpx.AsyncClient(timeout=5) as client: | |
| r = await client.get(url) | |
| return {"ready": r.status_code == 200} | |
| except: | |
| return {"ready": False} | |
| async def esperando(url: str = "", email: str = ""): | |
| return f"""<!DOCTYPE html> | |
| <html lang="es"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>TubeBot — Preparando tu canal...</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Manrope:wght@400;700&display=swap" rel="stylesheet"> | |
| <style> | |
| *{{box-sizing:border-box;margin:0;padding:0;}} | |
| body{{background:#080808;color:#f0f0f0;font-family:'Manrope',sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;}} | |
| .card{{background:#111;border:1px solid #222;border-radius:20px;padding:40px 32px;max-width:480px;width:100%;text-align:center;}} | |
| h1{{font-family:'Bebas Neue',sans-serif;font-size:2.5rem;color:#ff2d2d;margin-bottom:16px;}} | |
| .spinner{{width:60px;height:60px;border:4px solid #222;border-top:4px solid #ff2d2d;border-radius:50%;animation:spin 1s linear infinite;margin:24px auto;}} | |
| @keyframes spin{{to{{transform:rotate(360deg);}}}} | |
| p{{color:#888;font-size:.95rem;line-height:1.6;margin-bottom:12px;}} | |
| .status{{color:#ff2d2d;font-weight:700;font-size:1rem;margin-top:16px;}} | |
| .btn{{display:block;margin-top:24px;background:#ff2d2d;color:#fff;padding:14px;border-radius:10px;text-decoration:none;font-weight:800;font-family:'Bebas Neue',sans-serif;font-size:1.2rem;}} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <h1>🤖 CREANDO TU CANAL</h1> | |
| <div class="spinner"></div> | |
| <p>Tu sistema está siendo construido en nuestros servidores.</p> | |
| <p>Este proceso tarda entre <strong style="color:#fff;">3-5 minutos</strong> la primera vez.</p> | |
| <p class="status" id="status">⚡ Instalando dependencias...</p> | |
| <div style="background:#1a1a00;border:1px solid #D4AF37;border-radius:8px;padding:12px;margin-bottom:12px;font-size:.85rem;color:#D4AF37;text-align:left;"> | |
| ⚠️ <b>Aviso normal de Google:</b> Verás "App no verificada". Es seguro. Pulsa <b>"Configuración avanzada"</b> y luego <b>"Ir a TubeBot (no seguro)"</b>. Es porque la app aún está en revisión por Google. | |
| </div> | |
| <a href="/oauth-youtube?email={email}" id="yt-btn" target="_blank" class="btn" style="display:none;background:#ff0000;margin-bottom:12px;">📺 Conectar YouTube</a> | |
| <a href="{url}" class="btn" id="enlace" target="_blank" style="display:none;">🚀 ENTRAR A MI CANAL</a> | |
| </div> | |
| <script> | |
| const url = "{url}"; | |
| const mensajes = [ | |
| "⚡ Instalando dependencias...", | |
| "🧠 Configurando IA...", | |
| "🎙️ Preparando síntesis de voz...", | |
| "🎬 Configurando motor de video...", | |
| "🔥 Casi listo...", | |
| ]; | |
| let i = 0; | |
| const statusEl = document.getElementById('status'); | |
| const msgInterval = setInterval(() => {{ | |
| i = (i+1) % mensajes.length; | |
| statusEl.textContent = mensajes[i]; | |
| }}, 8000); | |
| async function checkReady() {{ | |
| try {{ | |
| const r = await fetch(`/check-space?url=${{encodeURIComponent(url)}}`); const d = await r.json(); if(!d.ready) throw new Error('not ready'); | |
| window.location.href = url; | |
| }} catch(e) {{}} | |
| }} | |
| const checkInterval = setInterval(checkReady, 15000); | |
| setTimeout(function(){{document.getElementById("yt-btn").style.display="block";}}, 180000); | |
| setTimeout(function(){{window.location.href=url;}}, 270000); | |
| </script> | |
| </body> | |
| </html>""" | |
| async def test_llaves(): | |
| return { | |
| "GROQ_KEY_existe": bool(os.environ.get("GROQ_KEY","")), | |
| "GROQ_KEY_longitud": len(os.environ.get("GROQ_KEY","")), | |
| "PEXELS_KEY_existe": bool(os.environ.get("PEXELS_KEY","")), | |
| "PEXELS_KEY_longitud": len(os.environ.get("PEXELS_KEY","")), | |
| } | |
| async def ver_clientes(): | |
| return CLIENTES | |
| async def test_token(): | |
| import secrets | |
| token = secrets.token_urlsafe(32) | |
| TOKENS_VALIDOS[token] = "test@test.com" | |
| return {"token": token, "url": f"/activar-canal?token={token}"} | |
| async def dashboard(): | |
| total = len(CLIENTES) | |
| conectados = sum(1 for c in CLIENTES.values() if c.get("youtube_conectado")) | |
| filas = "" | |
| for email, data in CLIENTES.items(): | |
| yt = "✅" if data.get("youtube_conectado") else "❌" | |
| url = data.get("url","") | |
| nicho = data.get("nicho","") | |
| filas += f"<tr><td>{email}</td><td>{nicho}</td><td>{yt}</td><td><a href='{url}' target='_blank'>Ver Space</a></td></tr>" | |
| return f"""<!DOCTYPE html> | |
| <html><head><meta charset=UTF-8> | |
| <style> | |
| body{{background:#0a0a0a;color:#fff;font-family:sans-serif;padding:30px;}} | |
| h1{{color:#ff2d2d;}} | |
| .stats{{display:flex;gap:30px;margin:20px 0;}} | |
| .stat{{background:#111;border:1px solid #333;border-radius:10px;padding:20px 30px;text-align:center;}} | |
| .stat-n{{font-size:2.5rem;color:#ff2d2d;font-weight:bold;}} | |
| .stat-l{{color:#666;font-size:.85rem;}} | |
| table{{width:100%;border-collapse:collapse;margin-top:20px;}} | |
| th{{background:#1a1a1a;padding:12px;text-align:left;color:#ff2d2d;}} | |
| td{{padding:12px;border-bottom:1px solid #222;}} | |
| a{{color:#ff2d2d;}} | |
| </style></head> | |
| <body> | |
| <h1>🤖 TubeBot Dashboard</h1> | |
| <div class="stats"> | |
| <div class="stat"><div class="stat-n">{total}</div><div class="stat-l">Clientes totales</div></div> | |
| <div class="stat"><div class="stat-n">{conectados}</div><div class="stat-l">YouTube conectado</div></div> | |
| <div class="stat"><div class="stat-n">{total-conectados}</div><div class="stat-l">Pendientes</div></div> | |
| </div> | |
| <table> | |
| <tr><th>Email</th><th>Nicho</th><th>YouTube</th><th>Space</th></tr> | |
| {filas} | |
| </table> | |
| </body></html>""" | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=7860) | |
| # ══════════════════════════════════════════ | |
| # YOUTUBE OAUTH | |
| # ══════════════════════════════════════════ | |
| GOOGLE_PROYECTOS = [ | |
| # (os.environ.get("GOOGLE_CLIENT_ID",""), os.environ.get("GOOGLE_CLIENT_SECRET","")), # AGOTADO | |
| # (os.environ.get("GOOGLE_CLIENT_ID_2",""), os.environ.get("GOOGLE_CLIENT_SECRET_2","")), # AGOTADO | |
| # (os.environ.get("GOOGLE_CLIENT_ID_3",""), os.environ.get("GOOGLE_CLIENT_SECRET_3","")), # AGOTADO | |
| # (os.environ.get("GOOGLE_CLIENT_ID_4",""), os.environ.get("GOOGLE_CLIENT_SECRET_4","")), # AGOTADO | |
| # (os.environ.get("GOOGLE_CLIENT_ID_5",""), os.environ.get("GOOGLE_CLIENT_SECRET_5","")), # AGOTADO | |
| (os.environ.get("GOOGLE_CLIENT_ID_6",""), os.environ.get("GOOGLE_CLIENT_SECRET_6","")), | |
| (os.environ.get("GOOGLE_CLIENT_ID_7",""), os.environ.get("GOOGLE_CLIENT_SECRET_7","")), | |
| (os.environ.get("GOOGLE_CLIENT_ID_8",""), os.environ.get("GOOGLE_CLIENT_SECRET_8","")), | |
| (os.environ.get("GOOGLE_CLIENT_ID_9",""), os.environ.get("GOOGLE_CLIENT_SECRET_9","")), | |
| ] | |
| GOOGLE_PROYECTOS = [(c,s) for c,s in GOOGLE_PROYECTOS if c and s] | |
| import datetime as _dt | |
| USOS_PROYECTO = {} # {idx: {"fecha": "2026-05-27", "count": 5}} | |
| def reset_si_nuevo_dia(idx): | |
| hoy = _dt.date.today().isoformat() | |
| if idx not in USOS_PROYECTO or USOS_PROYECTO[idx].get("fecha") != hoy: | |
| USOS_PROYECTO[idx] = {"fecha": hoy, "count": 0} | |
| def incrementar_uso(idx): | |
| reset_si_nuevo_dia(idx) | |
| USOS_PROYECTO[idx]["count"] += 1 | |
| def proyecto_disponible(): | |
| """Devuelve indice del proyecto con menos usos hoy (max 5 videos/dia)""" | |
| if not GOOGLE_PROYECTOS: | |
| return 0 | |
| contadores = [] | |
| for i in range(len(GOOGLE_PROYECTOS)): | |
| reset_si_nuevo_dia(i) | |
| contadores.append((USOS_PROYECTO[i]["count"], i)) | |
| contadores.sort() | |
| return contadores[0][1] # el indice con menos usos | |
| def get_proyecto_cliente(email): | |
| """Devuelve el proyecto Google asignado al cliente, o asigna uno nuevo""" | |
| if email in CLIENTES and "google_index" in CLIENTES[email]: | |
| idx = CLIENTES[email]["google_index"] | |
| if idx < len(GOOGLE_PROYECTOS): | |
| return GOOGLE_PROYECTOS[idx], idx | |
| # Asignar el proyecto con menos USOS hoy (mejor reparto de cuota) | |
| idx = proyecto_disponible() | |
| return GOOGLE_PROYECTOS[idx], idx | |
| # Default para arranque (se sobrescribe por cliente) | |
| if GOOGLE_PROYECTOS: | |
| GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET = GOOGLE_PROYECTOS[0] | |
| else: | |
| GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET = "", "" | |
| REDIRECT_URI = "https://cristobal299-iaweb.hf.space/oauth-callback" | |
| SCOPES = "https://www.googleapis.com/auth/youtube.upload" | |
| async def oauth_youtube(email: str, canal: int = 0): | |
| import urllib.parse | |
| (cli_id, cli_sec), idx = get_proyecto_cliente(email) | |
| if email in CLIENTES: | |
| CLIENTES[email]["google_index"] = idx | |
| guardar_clientes() | |
| params = { | |
| "client_id": cli_id, | |
| "redirect_uri": REDIRECT_URI, | |
| "response_type": "code", | |
| "scope": SCOPES, | |
| "access_type": "offline", | |
| "prompt": "consent", | |
| "state": f"{email}|{canal}" | |
| } | |
| url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(params) | |
| from fastapi.responses import RedirectResponse | |
| return RedirectResponse(url) | |
| async def oauth_callback(code: str, state: str): | |
| import urllib.parse | |
| if "|" in state: | |
| email, canal_idx = state.split("|", 1) | |
| canal_idx = int(canal_idx) | |
| else: | |
| email = state | |
| canal_idx = 0 | |
| (cli_id, cli_sec), idx = get_proyecto_cliente(email) | |
| async with httpx.AsyncClient() as client: | |
| r = await client.post("https://oauth2.googleapis.com/token", data={ | |
| "code": code, | |
| "client_id": cli_id, | |
| "client_secret": cli_sec, | |
| "redirect_uri": REDIRECT_URI, | |
| "grant_type": "authorization_code" | |
| }) | |
| token = r.json() | |
| if "access_token" not in token: | |
| return HTMLResponse("<h2>❌ Error al obtener token</h2>") | |
| # Guardar token en el Space del cliente | |
| if email in CLIENTES: | |
| canales_lista = CLIENTES[email].get("canales", []) | |
| space_repo = canales_lista[canal_idx].get("repo", "") if canal_idx < len(canales_lista) else "" | |
| import json, base64 | |
| cuentas = [ | |
| (os.environ.get("HF_TOKEN_5",""), os.environ.get("HF_USER_5","Alejandrl")), | |
| (os.environ.get("HF_TOKEN_4",""), os.environ.get("HF_USER_4","Maria288")), | |
| (os.environ.get("HF_TOKEN_2",""), os.environ.get("HF_USER_2","Cristobal2999")), | |
| (os.environ.get("HF_TOKEN_3",""), os.environ.get("HF_USER_3","Cristobal288")), | |
| ] | |
| user_repo = space_repo.split("/")[0] | |
| hf_token_repo = next((t for t,u in cuentas if u.lower()==user_repo.lower()), HF_TOKEN) | |
| token_data = dict(token) | |
| token_data["client_id"] = cli_id | |
| token_data["client_secret"] = cli_sec | |
| content_b64 = base64.b64encode(json.dumps(token_data).encode()).decode() | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| r = await client.post( | |
| f"https://huggingface.co/api/spaces/{space_repo}/commit/main", | |
| headers={"Authorization": f"Bearer {hf_token_repo}", "Content-Type": "application/x-ndjson"}, | |
| content=f'{{"key":"header","value":{{"summary":"add token"}}}}\n{{"key":"file","value":{{"path":"token.json","content":"{content_b64}","encoding":"base64"}}}}\n' | |
| ) | |
| print(f"Token upload: {r.status_code} {r.text[:200]}", flush=True) | |
| # Reiniciar Space del cliente para que lea el token | |
| r2 = await client.post( | |
| f"https://huggingface.co/api/spaces/{space_repo}/restart?factory=true", | |
| headers={"Authorization": f"Bearer {hf_token_repo}"} | |
| ) | |
| print(f"Restart: {r2.status_code}", flush=True) | |
| if canal_idx < len(CLIENTES[email].get("canales", [])): | |
| CLIENTES[email]["canales"][canal_idx]["youtube"] = True | |
| guardar_clientes() | |
| url_cliente = f"https://cristobal299-iaweb.hf.space/mi-cuenta?email={email}" | |
| nicho = CLIENTES.get(email, {}).get("nicho", "tu nicho") | |
| return HTMLResponse(f""" | |
| <html><head><meta charset="utf-8"><title>¡Bienvenido!</title></head> | |
| <body style='background:#080808;color:#fff;font-family:system-ui;text-align:center;padding:40px 20px;max-width:600px;margin:0 auto'> | |
| <div style='font-size:4rem'>🎉</div> | |
| <h1 style='color:#ff2d2d;font-size:2.5rem;margin:20px 0'>¡BIENVENIDO A TUBEBOT!</h1> | |
| <p style='color:#D4AF37;font-size:1.3rem;font-weight:700'>Tu canal de {nicho} está ACTIVO ✅</p> | |
| <div style='background:#111;border:1px solid #333;border-radius:12px;padding:25px;margin:30px 0;text-align:left'> | |
| <h3 style='color:#ff2d2d;margin-top:0'>🚀 Tu sistema ya está funcionando:</h3> | |
| <p>✅ Generando guión con IA</p> | |
| <p>✅ Voz profesional sintética</p> | |
| <p>✅ Fondo de video automático</p> | |
| <p>✅ Subtítulos animados</p> | |
| <p>✅ Publica solo en YouTube cada 4 horas</p> | |
| </div> | |
| <a id="panel-btn" href="{url_cliente}" style='display:none;background:#ff0000;color:#fff;padding:20px;border-radius:10px;text-decoration:none;font-weight:800;font-size:1.2rem;margin:20px 0'>📺 IR A MI PANEL</a> | |
| <div id="esperando-msg" style='background:#1a1a00;border:1px solid #D4AF37;border-radius:10px;padding:20px;margin:20px 0;color:#D4AF37'>⏳ Reiniciando tu sistema con YouTube conectado... <b>Espera 2 minutos.</b></div> | |
| <script>setTimeout(()=>{{document.getElementById("panel-btn").style.display="block";document.getElementById("esperando-msg").style.display="none";}}, 120000);</script> | |
| <p style='color:#888;margin-top:30px;font-size:0.9rem'>💡 Guarda esta URL en favoritos:<br><code style='color:#D4AF37'>{url_cliente}</code></p> | |
| <p style='color:#888;margin-top:20px;font-size:0.9rem'>¿Problemas? WhatsApp: +34656691085</p> | |
| </body></html> | |
| """) | |
| # restart | |
| async def reportar_uso(request: Request): | |
| """Endpoint que cada cliente Space llama cuando sube un video""" | |
| try: | |
| data = await request.json() | |
| idx = data.get("proyecto_idx", -1) | |
| email = data.get("email", "?") | |
| titulo = data.get("titulo", "?") | |
| if 0 <= idx < len(GOOGLE_PROYECTOS): | |
| incrementar_uso(idx) | |
| avisar_telegram(f"📺 <b>Video subido a YouTube</b>\n📧 {email}\n🎬 {titulo[:60]}\n📊 Proyecto {idx+1}: {USOS_PROYECTO[idx]['count']} videos hoy") | |
| return {"ok": True, "usos_hoy": USOS_PROYECTO[idx]["count"]} | |
| except Exception as e: | |
| print(f"Error reportar uso: {e}", flush=True) | |
| return {"ok": False} | |
| async def estado_proyectos(): | |
| """Ver estado de los proyectos Google""" | |
| estado = [] | |
| for i in range(len(GOOGLE_PROYECTOS)): | |
| reset_si_nuevo_dia(i) | |
| estado.append({"proyecto": i+1, "usos_hoy": USOS_PROYECTO[i]["count"]}) | |
| return estado | |