Carga masiva de vídeos en YouTube: cuotas de Data API y automatización de Studio


Anton Misiul
Customer Service Specialist
La carga masiva de vídeos en YouTube se encuentra rápidamente con las limitaciones de la plataforma. Cuando se trata de cientos de vídeos y varios canales, el trabajo manual a través de YouTube Studio deja de ser eficiente, mientras que pasar a YouTube Data API añade sus propias limitaciones, desde las cuotas de carga hasta las restricciones de acceso a los vídeos.
En este artículo analizaremos las principales formas de realizar una carga masiva de vídeos, las limitaciones de YouTube y las opciones para construir un sistema de este tipo. También calcularemos por separado las cuotas actuales de Data API: a finales de 2025, Google cambió el coste de las cargas de vídeo y, en junio de 2026, comenzó la transición a un modelo de cuotas más granular, por lo que muchas guías antiguas ya no corresponden a las reglas actuales. Al final, prepararemos un script funcional de Node.js para subir vídeos a varios canales mediante perfiles aislados de Octo Browser.
Contenidos
Manténgase en el anonimato, aproveche las ventajas del multicuentas y alcance sus objetivos con el navegador antidetección de mayor calidad del mercado.
¿Te gustaría probar Octo Browser con descuento?
Usa el código promocional OCTOBLOG para obtener un 30% de descuento en cualquier suscripción. Esta oferta solo es válida para nuevos usuarios.
Dos tareas, dos enfoques
Antes de elegir un método de automatización, hay que definir cuál es realmente tu tarea:
Carga masiva de vídeos en un solo canal. Doscientos vídeos para un canal con clases educativas, un archivo de webinars o una recopilación de recortes de podcasts. Aquí el cuello de botella es el límite diario del propio canal, que, como veremos más adelante, no se puede eludir.
Trabajo con varios canales. Versiones localizadas del mismo contenido para diferentes países, canales para distintas ofertas, canales de clientes de una agencia. Aquí la cuestión principal es cómo mantener veinte cuentas de Google lo suficientemente separadas para que YouTube no las vincule entre sí y las bloquee todas en masa.
La primera tarea se resuelve con planificación y publicación programada. La segunda requiere aislamiento de perfiles y automatización. Veamos ambos enfoques.
Funciones nativas de YouTube para la carga masiva
Antes de escribir scripts, comprueba si las funciones integradas de YouTube ya son suficientes para tus necesidades. YouTube Studio permite subir varios archivos a la vez: en el diálogo de carga puedes seleccionar un grupo de vídeos, todos se añadirán como borradores y después podrás editar sus metadatos en masa mediante las casillas de selección. También puedes configurar la publicación programada, por lo que es posible distribuir veinte borradores a lo largo del mes siguiente.
Para trabajar regularmente con un solo canal, esto suele ser suficiente. La automatización empieza a valer la pena cuando hay más de cincuenta vídeos, más de tres canales, o cuando los metadatos se generan mediante programación y se almacenan en un archivo CSV o en una base de datos.
YouTube Data API v3 y sus dos límites
La vía oficial para subir vídeos es el método videos.insert. Admite cargas reanudables, acepta archivos de hasta 256 GB y funciona únicamente mediante OAuth 2.0. Las cuentas de servicio no son adecuadas para subir vídeos en nombre de un canal, lo que hace fracasar desde el primer momento cualquier intento de crear un cargador del lado del servidor sin autorización interactiva: tendrás que obtener manualmente un refresh token al menos una vez por canal.
Pero el problema principal no es la autorización.
La cuota: casi todas las guías sobre este tema están desactualizadas
Si anteriormente buscaste información sobre cargas masivas, casi seguro que te topaste con el cálculo «10.000 unidades divididas entre 1.600 por carga da seis vídeos al día». Ese cálculo ya no es válido, y esto es lo principal que hay que entender en 2026.
Google modificó el modelo de cuotas dos veces. El 4 de diciembre de 2025, el coste de subir un vídeo se redujo de aproximadamente 1.600 unidades a aproximadamente 100. Después, el 1 de junio de 2026, la API pasó a un sistema de cuotas granular: las llamadas videos.insert y search.list se descuentan de sus propios buckets independientes, en lugar de utilizar un fondo común.
La cuota predeterminada actual de un proyecto es la siguiente:
Bucket | Límite predeterminado |
|---|---|
| 100 llamadas al día |
| 100 llamadas al día |
Todos los demás métodos | 10.000 unidades al día en total |
Los buckets son independientes: una vez agotadas las cien cargas, recibirás un 403 de videos.insert, aunque queden 9.000 unidades intactas en el fondo común.
El coste de los métodos dentro del fondo común no ha cambiado:
las lecturas cuestan 1 unidad;
las escrituras, como
videos.updateythumbnails.set, cuestan 50;captions.insertes considerablemente más caro: 400.
Cuidado con la documentación de Google: en el momento de redactar este artículo se contradice a sí misma. La página «Calculadora de cuotas» se actualizó en agosto de 2025 y todavía muestra el antiguo coste de 1.600 unidades para videos.insert. Las fuentes actualizadas son la versión inglesa de la descripción general y la entrada del 1 de junio de 2026 en Revision History. Los límites reales de tu proyecto deben comprobarse siempre en Google Cloud Console: los valores de cuota pueden variar entre proyectos antiguos y nuevos.
Conclusión: cien cargas al día por proyecto ya no son el cuello de botella que obliga a recurrir a soluciones alternativas. Las verdaderas limitaciones están en otro plano.
Primer límite: auditoría y locked private
Hablemos del motivo por el que los cargadores personalizados suelen resultar inútiles, independientemente de los cambios en las cuotas. Todos los vídeos subidos mediante videos.insert desde un proyecto de API no verificado creado después del 28 de julio de 2020 pasan obligatoriamente al modo privado. El propietario del canal recibe un correo indicando que el vídeo está bloqueado como privado porque se ha subido mediante un cliente no verificado.
No existe una vía de apelación para este tipo de bloqueo. La única salida es volver a subir el vídeo mediante un cliente oficial o auditado, o directamente desde el sitio web de YouTube. Es decir, antes de pasar la auditoría, el cargador funciona formalmente, devuelve 200 OK y proporciona el ID del vídeo, pero nadie salvo tú podrá ver el resultado de su trabajo.
Los proyectos creados antes de julio de 2020 no están sujetos a esta regla. Si tienes uno de estos proyectos, estás en una posición favorable. Para todos los demás, será necesario pasar la auditoría o renunciar a las cargas públicas mediante la API.
Segundo límite: el límite diario de cargas del canal
Es la restricción más molesta porque no se puede eludir de ninguna manera. YouTube limita la cantidad de vídeos que un canal puede subir en un periodo de 24 horas y no publica cifras exactas. Se sabe que el límite depende del historial del canal, de la región y de la existencia de strikes, y que se aplica simultáneamente a la versión web, las aplicaciones móviles y la API. Según observaciones de la comunidad, en canales nuevos son aproximadamente 10–20 vídeos, mientras que en canales con una larga trayectoria pueden ser hasta un centenar, pero se trata de observaciones, no de valores documentados.
En la API, este límite aparece como un error independiente uploadLimitExceeded con código 400, y es importante distinguirlo del agotamiento de la cuota del proyecto: el primero corresponde al canal y el segundo al proyecto.
Si alcanzas el límite del canal, tendrás que esperar 24 horas. Cambiar el método de carga o contactar con soporte no ayudará. La única forma efectiva de aumentar la capacidad es no subir más vídeos a un solo canal, sino distribuir la carga entre varios.
Conclusiones para la arquitectura
Con el nuevo modelo de cuotas, la cuestión principal ya no es el número de solicitudes a la API. Cien cargas al día por proyecto son suficientes para la mayoría de las tareas. Sin embargo, subir vídeos mediante la API antes de pasar la auditoría es inútil para el contenido público, y un solo canal no puede escalar verticalmente independientemente del método de carga. Por tanto, solo queda la escalabilidad horizontal: más canales y menos cargas por canal.
Y cuando gestionas veinte canales, necesitas una estrategia estructurada de multicuenta. Google vincula las cuentas de forma más agresiva que la mayoría de las plataformas: mediante la huella del dispositivo, las sesiones de cookies compartidas, los Client Hints coincidentes y las direcciones IP. Veinte canales desde un mismo Chrome y bajo una misma IP terminarán formando un único clúster, que puede ser bloqueado por completo.
Por eso, la arquitectura de la solución es la siguiente:
Navegador antidetecсión Octo Browser: un perfil aislado por canal, cada uno con su propia huella digital y su propio proxy. Los perfiles mantienen sesiones activas de Google, por lo que aquí no sirven los perfiles desechables: se necesitan perfiles persistentes. Es práctico organizarlos en carpetas, de modo que los canales de un mismo proyecto estén agrupados.
Rebrowser-puppeteer: un fork de Puppeteer sin las filtraciones características que delatan la automatización.
Local API de Octo: inicia los perfiles y se conecta a ellos mediante CDP. La descripción completa de los métodos está disponible en la documentación y en la referencia de la API.
Cola de tareas en JSON: un archivo con la lista de perfiles, los vídeos asociados a cada uno y sus metadatos. Así, el script se mantiene universal y todos los datos específicos quedan en el archivo de entrada.
Cómo trabajar con los selectores de Studio
La interfaz de YouTube Studio está construida con componentes web de Polymer, y el enfoque para los selectores aquí es distinto del que utilizamos al hacer scraping de Instagram.
La mala noticia: las clases de Studio se generan dinámicamente y tienen una vida corta. La buena: los elementos clave tienen identificadores y atributos estables que no dependen del idioma de la interfaz. El botón para pasar al siguiente paso es siempre #next-button. Los botones de opción de visibilidad son tp-yt-paper-radio-button[name="PRIVATE"], [name="UNLISTED"], [name="PUBLIC"]. El selector para indicar que el contenido no está destinado a niños es [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]. Ninguno de ellos cambia al cambiar el idioma de la cuenta.
La conclusión práctica es clara: utiliza atributos e identificadores en lugar del texto de los botones. Un script que busque un botón por la palabra «Siguiente» fallará en cuanto se ejecute en un perfil con la interfaz en otro idioma. Un script basado en #next-button funcionará con cualquier idioma, y no será necesario fijar forzosamente la configuración regional mediante ?hl=en.
El segundo truco tiene que ver con la propia carga del archivo. Hacer clic en la zona de arrastrar y soltar no sirve de nada: abrirá el diálogo nativo del sistema operativo, que no se puede cerrar desde el navegador. En su lugar, el archivo se pasa directamente al input[type="file"] oculto mediante uploadFile(). De este modo, el diálogo del sistema operativo ni siquiera aparece.
Script listo para subir vídeos a varios canales
El script recorre secuencialmente los perfiles de Octo, abre Studio en cada uno y sube los vídeos asociados al perfil: completa el título, la descripción y las etiquetas, marca el contenido como no destinado a niños, define la visibilidad y espera a que finalice el procesamiento. Entre las cargas realiza pausas aleatorias, contabiliza las cargas de cada canal y se detiene al alcanzar el límite diario configurado.
Preparación de los perfiles
Crea un perfil de Octo por cada canal y asigna un proxy distinto a cada uno. Es preferible utilizar un proxy residencial o móvil. Antes de trabajar, conviene comprobar su fiabilidad.
Abre cada perfil manualmente e inicia sesión en la cuenta de Google. Entra en Studio, acepta todos los diálogos iniciales y confirma el número de teléfono si es necesario. El script no debería toparse con el proceso de incorporación inicial.
Sube manualmente un vídeo desde cada perfil. Así comprobarás que el canal está preparado para aceptar cargas y, al mismo tiempo, resolverás cualquier cuestión relacionada con la verificación.
Copia los UUID de los perfiles desde el cliente de Octo. Los necesitarás para el archivo de cola.
El archivo de cola
Crea upload_queue.json junto al script. La estructura es la siguiente:
[ { "uuid": "profile_UUID_1", "channel": "channel_name_1", "videos": [ { "file": "file_path_1", "title": "YouTube_video_title", "description": "YouTube_video_description", "tags": ["tag1", "tag2"], "visibility": "PUBLIC" }, { "file": "file_path_2", "title": "YouTube_video_title", "description": "YouTube_video_description", "tags": ["tag1", "tag2"], "visibility": "UNLISTED" } ] }, { "uuid": "profile_UUID_2", "channel": "channel_name_2", "videos": [ { "file": "file_path_3", "title": "YouTube_video_title", "description": "YouTube_video_description", "tags": ["tag1", "tag2"], "visibility": "PRIVATE" } ] } ]
[ { "uuid": "profile_UUID_1", "channel": "channel_name_1", "videos": [ { "file": "file_path_1", "title": "YouTube_video_title", "description": "YouTube_video_description", "tags": ["tag1", "tag2"], "visibility": "PUBLIC" }, { "file": "file_path_2", "title": "YouTube_video_title", "description": "YouTube_video_description", "tags": ["tag1", "tag2"], "visibility": "UNLISTED" } ] }, { "uuid": "profile_UUID_2", "channel": "channel_name_2", "videos": [ { "file": "file_path_3", "title": "YouTube_video_title", "description": "YouTube_video_description", "tags": ["tag1", "tag2"], "visibility": "PRIVATE" } ] } ]
El campo visibility acepta tres valores: PRIVATE, UNLISTED y PUBLIC. Si generas la cola a partir de una hoja de cálculo o una base de datos, basta con exportarla en este mismo formato.
Ejecución
Descarga e instala VS Code.
Descarga e instala Node.js.
Crea una carpeta, por ejemplo
octo_youtube_uploader, y ábrela en VS Code.Crea el archivo
octo_youtube_uploader.jsy pega en él el código del script.Coloca junto a él el archivo
upload_queue.jsoncon tus perfiles y vídeos.Comprueba los parámetros de la variable
config. Los principales sondaily_limit_per_channel(cuántos vídeos subir a un canal en cada ejecución),delay_between_uploads(la pausa entre cargas en segundos), yupload_timeout_min(cuántos minutos esperar a que se procese un vídeo).Abre el terminal y ejecuta
npm i rebrowser-puppeteer axios.Inicia Octo Browser.
Ejecuta el script (Ctrl/Cmd + F5) y sigue el progreso en la consola.
Si VS Code muestra un error al instalar las dependencias, abre PowerShell como administrador, ejecuta
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, confirma y repite la instalación.
Ten en cuenta que la carga y el procesamiento de un vídeo tardan minutos, no segundos. Una ejecución de veinte vídeos puede tardar horas, y es normal. No reduzcas las pausas para acelerar el proceso: son precisamente los intervalos cortos y uniformes entre acciones los que delatan la automatización.
Código del script
const axios = require('axios'); const puppeteer = require('rebrowser-puppeteer'); const fs = require('fs').promises; const path = require('path'); const config = { octo_local_api_base_url: 'http://localhost:58888/api/profiles', headless_mode: false, queue_file: 'upload_queue.json', results_dir: 'youtube_results', daily_limit_per_channel: 8, upload_timeout_min: 40, // These two pauses account for roughly 90% of the total run time. // Shortening them speeds things up but makes the pattern easier to // spot, so treat it as a risk decision rather than an optimization. delay_between_uploads: { min: 180, max: 420 }, delay_between_profiles: { min: 120, max: 300 } }; const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; const SELECTORS = { file: 'input[type="file"]', title: ['#title-textarea #textbox', 'ytcp-social-suggestions-textbox#title-textarea #textbox'], description: ['#description-textarea #textbox', 'ytcp-video-description #textbox'], tags_toggle: '#toggle-button', tags_input: '#tags-container input', not_for_kids: 'tp-yt-paper-radio-button[name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]', next: '#next-button', done: '#done-button', progress: '.progress-label', dialogs: ['#close-button', 'ytcp-dialog #dismiss-button'] }; const random_range = (min, max) => min + Math.random() * (max - min); const sleep = seconds => new Promise(r => setTimeout(r, seconds * 1000)); const human_delay = (min_ms = 80, max_ms = 300) => sleep(random_range(min_ms, max_ms) / 1000); async function check_limits(response) { for (const entry of (response.headers.ratelimit || '').split(',')) { const left = entry.match(/;r=(\d+)/); const reset = entry.match(/;t=(\d+)/); if (left && reset && +left[1] < 5) { const wait = +reset[1] + 1; console.log(`⏳ Octo rate limit reached, waiting ${wait}s`); await sleep(wait); } } } async function octo_call(action, body) { const res = await axios.post(`${config.octo_local_api_base_url}/${action}`, body, { headers: { 'Content-Type': 'application/json' } }); await check_limits(res); return res.data; } // Resolves all selectors in a single round trip instead of one call per // selector: on a 0.5s poll that adds up over a long run. async function wait_for_any(page, selectors, timeout = 30000) { const list = Array.isArray(selectors) ? selectors : [selectors]; const started = Date.now(); while (Date.now() - started < timeout) { const hit = await page.evaluate(sels => sels.find(s => { const el = document.querySelector(s); if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }), list); if (hit) return await page.$(hit); await sleep(0.5); } throw new Error(`None of the selectors matched: ${list.join(' | ')}`); } async function type_into_box(page, handle, text) { await handle.click(); await human_delay(200, 500); await page.keyboard.down(MOD_KEY); await page.keyboard.press('KeyA'); await page.keyboard.up(MOD_KEY); await page.keyboard.press('Backspace'); await human_delay(150, 400); for (const chunk of text.split(/(?<=\s)/)) { await page.keyboard.type(chunk, { delay: random_range(15, 55) }); if (Math.random() < 0.15) await human_delay(200, 700); } } async function dismiss_dialogs(page) { for (const selector of SELECTORS.dialogs) { const handle = await page.$(selector); if (!handle) continue; await handle.click().catch(() => { }); await human_delay(500, 1200); } } // Closes tabs we do not need. Studio opens some on its own, and Octo // persists the session, so leftovers reappear on the next launch. // Without `keep` the last surviving tab is parked on about:blank. async function prune_tabs(browser, keep = null) { try { const pages = await browser.pages(); const survivor = keep || pages[0]; for (const p of pages) { if (p !== survivor) await p.close().catch(() => { }); } if (!keep && survivor) { await survivor.goto('about:blank', { timeout: 15000 }).catch(() => { }); } } catch (e) { console.log(` ⚠️ Tab cleanup failed: ${e.message}`); } } async function wait_for_processing(page, timeout_min) { const deadline = Date.now() + timeout_min * 60 * 1000; let last_report = 0; while (Date.now() < deadline) { const percent = await page.evaluate(sel => { const el = document.querySelector(sel); const m = el && el.textContent.match(/(\d+)\s*%/); return m ? Number(m[1]) : null; }, SELECTORS.progress); // The percentage disappearing from the label means the upload is // done and Studio switched to its processing or checks message. if (percent === null || percent >= 100) return true; if (percent - last_report >= 10) { console.log(` ⬆️ Uploaded ${percent}%`); last_report = percent; } await sleep(5); } return false; } async function fill_metadata(page, video) { const title_box = await wait_for_any(page, SELECTORS.title, 90000); await human_delay(1500, 3000); await type_into_box(page, title_box, video.title); console.log(` ✏️ Title: ${video.title}`); if (video.description?.trim()) { const box = await wait_for_any(page, SELECTORS.description); await type_into_box(page, box, video.description); console.log(' 📝 Description filled in'); } if (video.tags?.length) { const toggle = await page.$(SELECTORS.tags_toggle); if (toggle) { await toggle.click(); await human_delay(1000, 2000); const input = await page.$(SELECTORS.tags_input); if (input) { await input.click(); for (const tag of video.tags) { await page.keyboard.type(tag, { delay: random_range(20, 60) }); await page.keyboard.press('Comma'); await human_delay(300, 800); } console.log(` 🏷 Tags: ${video.tags.join(', ')}`); } } } const not_for_kids = await page.$(SELECTORS.not_for_kids); if (not_for_kids) { await not_for_kids.click(); await human_delay(600, 1400); } } async function upload_one(page, video) { console.log(`\n🎬 ${path.basename(video.file)}`); await page.goto('https://www.youtube.com/upload', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(2000, 4000); await dismiss_dialogs(page); const file_input = await page.waitForSelector(SELECTORS.file, { timeout: 45000 }); await file_input.uploadFile(video.file); console.log(' 📤 File handed to the input, waiting for the metadata dialog'); await fill_metadata(page, video); // Three wizard steps: details, video elements, checks. for (let step = 0; step < 3; step++) { const next = await wait_for_any(page, SELECTORS.next); await next.click(); await human_delay(1500, 3000); } const visibility = video.visibility || 'PRIVATE'; const radio = await wait_for_any(page, `tp-yt-paper-radio-button[name="${visibility}"]`); await radio.click(); console.log(` 👁 Visibility: ${visibility}`); await human_delay(1000, 2000); if (!await wait_for_processing(page, config.upload_timeout_min)) { console.log(' ⚠️ Processing timed out, the video stays a draft'); return { file: video.file, success: false, reason: 'processing_timeout' }; } const done = await wait_for_any(page, SELECTORS.done, 60000); await done.click(); await human_delay(3000, 6000); await dismiss_dialogs(page); await prune_tabs(page.browser(), page); console.log(' ✅ Published'); return { file: video.file, success: true, visibility }; } async function process_profile(entry, index, total) { const line = '='.repeat(70); console.log(`\n${line}\n📋 Profile ${index + 1}/${total} — ${entry.channel} (${entry.uuid})`); console.log(` Videos queued: ${entry.videos.length}\n${line}`); const stats = { channel: entry.channel, uuid: entry.uuid, status: 'ok', uploads: [] }; await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(3); let start_data; try { start_data = await octo_call('start', { uuid: entry.uuid, headless: config.headless_mode, debug_port: true, timeout: 120 }); } catch (e) { const body = e.response?.data ? JSON.stringify(e.response.data) : e.message; console.error(`❌ Profile failed to start: ${body}`); return { ...stats, status: 'start_failed' }; } if (!start_data?.ws_endpoint) { console.error('❌ Octo returned no ws_endpoint'); await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); return { ...stats, status: 'no_ws' }; } let browser; try { browser = await puppeteer.connect({ browserWSEndpoint: start_data.ws_endpoint, defaultViewport: null, protocolTimeout: 3600000 }); // The profile starts with a tab already open. Reuse it, otherwise // a second, empty one shows up next to it. const pages = await browser.pages(); const page = pages[0] || await browser.newPage(); await page.setViewport({ width: 1440, height: 900 }); await page.goto('https://studio.youtube.com/', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(1500, 2500); if (page.url().includes('accounts.google.com')) { console.error('❌ Profile is not signed in to Google'); return { ...stats, status: 'not_logged_in' }; } const limit = Math.min(entry.videos.length, config.daily_limit_per_channel); for (let i = 0; i < limit; i++) { try { stats.uploads.push(await upload_one(page, entry.videos[i])); } catch (e) { console.error(`❌ Upload error: ${e.message}`); stats.uploads.push({ file: entry.videos[i].file, success: false, reason: e.message }); } if (i < limit - 1) { const pause = random_range(config.delay_between_uploads.min, config.delay_between_uploads.max); console.log(`⏰ Pause before the next video: ${Math.round(pause)}s`); await sleep(pause); } } if (entry.videos.length > limit) { console.log(`ℹ️ Left in the queue for tomorrow: ${entry.videos.length - limit}`); } } catch (e) { console.error(`❌ Profile processing error: ${e.message}`); stats.status = 'error'; stats.error = e.message; } finally { if (browser) { await prune_tabs(browser); browser.disconnect(); } await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(2); } return stats; } // Validate the queue before launching a browser. An empty title or a wrong // file path would otherwise pass silently: there is nothing to type, no // error is raised, and the video lands on YouTube named after its file. async function validate_queue(queue) { const VISIBILITY = ['PRIVATE', 'UNLISTED', 'PUBLIC']; if (!Array.isArray(queue) || !queue.length) return ['the queue is empty or is not an array']; const problems = []; for (const [i, entry] of queue.entries()) { const where = `entry ${i + 1} (${entry.channel || 'unnamed'})`; if (!entry.uuid) problems.push(`${where}: no profile uuid`); if (!Array.isArray(entry.videos) || !entry.videos.length) { problems.push(`${where}: no videos at all`); continue; } for (const [j, video] of entry.videos.entries()) { const at = `${where}, video ${j + 1}`; const readable = video.file && await fs.access(video.file).then(() => true, () => false); if (!video.file) problems.push(`${at}: no file path`); else if (!readable) problems.push(`${at}: file not found — ${video.file}`); if (!video.title?.trim()) problems.push(`${at}: empty title`); else if (video.title.length > 100) problems.push(`${at}: title over 100 chars (${video.title.length})`); if (video.description?.length > 5000) problems.push(`${at}: description over 5000 chars`); if (video.visibility && !VISIBILITY.includes(video.visibility)) { problems.push(`${at}: visibility "${video.visibility}", allowed: ${VISIBILITY.join(', ')}`); } } } return problems; } (async () => { console.log('🚀 Octo YouTube Bulk Uploader'); const queue = JSON.parse(await fs.readFile(path.join(__dirname, config.queue_file), 'utf8')); const problems = await validate_queue(queue); if (problems.length) { console.error(`\n❌ Queue validation failed (${problems.length}):`); for (const p of problems) console.error(` • ${p}`); console.error('\nFix the queue and run again. No browser was launched.'); process.exit(1); } const total_videos = queue.reduce((sum, e) => sum + e.videos.length, 0); console.log(` ✅ Queue validated — ${queue.length} channels, ${total_videos} videos`); console.log(` Per-channel limit for this run: ${config.daily_limit_per_channel}\n`); const results_dir = path.join(__dirname, config.results_dir); await fs.mkdir(results_dir, { recursive: true }); const all_stats = []; for (let i = 0; i < queue.length; i++) { all_stats.push(await process_profile(queue[i], i, queue.length)); if (i < queue.length - 1) { const pause = random_range(config.delay_between_profiles.min, config.delay_between_profiles.max); console.log(`\n⏰ Pause before the next profile: ${Math.round(pause)}s`); await sleep(pause); } } console.log(`\n${'='.repeat(70)}\n📊 SUMMARY\n${'='.repeat(70)}`); for (const s of all_stats) { const ok = s.uploads.filter(u => u.success).length; console.log(`\n${s.channel} → ${s.status}, succeeded ${ok}/${s.uploads.length}`); for (const u of s.uploads) { console.log(u.success ? ` ✅ ${path.basename(u.file)} → ${u.visibility}` : ` ❌ ${path.basename(u.file)}: ${u.reason}`); } } const report_path = path.join(results_dir, `report_${Date.now()}.json`); await fs.writeFile(report_path, JSON.stringify(all_stats, null, 2), 'utf8'); console.log(`\n📄 Report: ${report_path}\n🎉 Done.`); })();
const axios = require('axios'); const puppeteer = require('rebrowser-puppeteer'); const fs = require('fs').promises; const path = require('path'); const config = { octo_local_api_base_url: 'http://localhost:58888/api/profiles', headless_mode: false, queue_file: 'upload_queue.json', results_dir: 'youtube_results', daily_limit_per_channel: 8, upload_timeout_min: 40, // These two pauses account for roughly 90% of the total run time. // Shortening them speeds things up but makes the pattern easier to // spot, so treat it as a risk decision rather than an optimization. delay_between_uploads: { min: 180, max: 420 }, delay_between_profiles: { min: 120, max: 300 } }; const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; const SELECTORS = { file: 'input[type="file"]', title: ['#title-textarea #textbox', 'ytcp-social-suggestions-textbox#title-textarea #textbox'], description: ['#description-textarea #textbox', 'ytcp-video-description #textbox'], tags_toggle: '#toggle-button', tags_input: '#tags-container input', not_for_kids: 'tp-yt-paper-radio-button[name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]', next: '#next-button', done: '#done-button', progress: '.progress-label', dialogs: ['#close-button', 'ytcp-dialog #dismiss-button'] }; const random_range = (min, max) => min + Math.random() * (max - min); const sleep = seconds => new Promise(r => setTimeout(r, seconds * 1000)); const human_delay = (min_ms = 80, max_ms = 300) => sleep(random_range(min_ms, max_ms) / 1000); async function check_limits(response) { for (const entry of (response.headers.ratelimit || '').split(',')) { const left = entry.match(/;r=(\d+)/); const reset = entry.match(/;t=(\d+)/); if (left && reset && +left[1] < 5) { const wait = +reset[1] + 1; console.log(`⏳ Octo rate limit reached, waiting ${wait}s`); await sleep(wait); } } } async function octo_call(action, body) { const res = await axios.post(`${config.octo_local_api_base_url}/${action}`, body, { headers: { 'Content-Type': 'application/json' } }); await check_limits(res); return res.data; } // Resolves all selectors in a single round trip instead of one call per // selector: on a 0.5s poll that adds up over a long run. async function wait_for_any(page, selectors, timeout = 30000) { const list = Array.isArray(selectors) ? selectors : [selectors]; const started = Date.now(); while (Date.now() - started < timeout) { const hit = await page.evaluate(sels => sels.find(s => { const el = document.querySelector(s); if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }), list); if (hit) return await page.$(hit); await sleep(0.5); } throw new Error(`None of the selectors matched: ${list.join(' | ')}`); } async function type_into_box(page, handle, text) { await handle.click(); await human_delay(200, 500); await page.keyboard.down(MOD_KEY); await page.keyboard.press('KeyA'); await page.keyboard.up(MOD_KEY); await page.keyboard.press('Backspace'); await human_delay(150, 400); for (const chunk of text.split(/(?<=\s)/)) { await page.keyboard.type(chunk, { delay: random_range(15, 55) }); if (Math.random() < 0.15) await human_delay(200, 700); } } async function dismiss_dialogs(page) { for (const selector of SELECTORS.dialogs) { const handle = await page.$(selector); if (!handle) continue; await handle.click().catch(() => { }); await human_delay(500, 1200); } } // Closes tabs we do not need. Studio opens some on its own, and Octo // persists the session, so leftovers reappear on the next launch. // Without `keep` the last surviving tab is parked on about:blank. async function prune_tabs(browser, keep = null) { try { const pages = await browser.pages(); const survivor = keep || pages[0]; for (const p of pages) { if (p !== survivor) await p.close().catch(() => { }); } if (!keep && survivor) { await survivor.goto('about:blank', { timeout: 15000 }).catch(() => { }); } } catch (e) { console.log(` ⚠️ Tab cleanup failed: ${e.message}`); } } async function wait_for_processing(page, timeout_min) { const deadline = Date.now() + timeout_min * 60 * 1000; let last_report = 0; while (Date.now() < deadline) { const percent = await page.evaluate(sel => { const el = document.querySelector(sel); const m = el && el.textContent.match(/(\d+)\s*%/); return m ? Number(m[1]) : null; }, SELECTORS.progress); // The percentage disappearing from the label means the upload is // done and Studio switched to its processing or checks message. if (percent === null || percent >= 100) return true; if (percent - last_report >= 10) { console.log(` ⬆️ Uploaded ${percent}%`); last_report = percent; } await sleep(5); } return false; } async function fill_metadata(page, video) { const title_box = await wait_for_any(page, SELECTORS.title, 90000); await human_delay(1500, 3000); await type_into_box(page, title_box, video.title); console.log(` ✏️ Title: ${video.title}`); if (video.description?.trim()) { const box = await wait_for_any(page, SELECTORS.description); await type_into_box(page, box, video.description); console.log(' 📝 Description filled in'); } if (video.tags?.length) { const toggle = await page.$(SELECTORS.tags_toggle); if (toggle) { await toggle.click(); await human_delay(1000, 2000); const input = await page.$(SELECTORS.tags_input); if (input) { await input.click(); for (const tag of video.tags) { await page.keyboard.type(tag, { delay: random_range(20, 60) }); await page.keyboard.press('Comma'); await human_delay(300, 800); } console.log(` 🏷 Tags: ${video.tags.join(', ')}`); } } } const not_for_kids = await page.$(SELECTORS.not_for_kids); if (not_for_kids) { await not_for_kids.click(); await human_delay(600, 1400); } } async function upload_one(page, video) { console.log(`\n🎬 ${path.basename(video.file)}`); await page.goto('https://www.youtube.com/upload', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(2000, 4000); await dismiss_dialogs(page); const file_input = await page.waitForSelector(SELECTORS.file, { timeout: 45000 }); await file_input.uploadFile(video.file); console.log(' 📤 File handed to the input, waiting for the metadata dialog'); await fill_metadata(page, video); // Three wizard steps: details, video elements, checks. for (let step = 0; step < 3; step++) { const next = await wait_for_any(page, SELECTORS.next); await next.click(); await human_delay(1500, 3000); } const visibility = video.visibility || 'PRIVATE'; const radio = await wait_for_any(page, `tp-yt-paper-radio-button[name="${visibility}"]`); await radio.click(); console.log(` 👁 Visibility: ${visibility}`); await human_delay(1000, 2000); if (!await wait_for_processing(page, config.upload_timeout_min)) { console.log(' ⚠️ Processing timed out, the video stays a draft'); return { file: video.file, success: false, reason: 'processing_timeout' }; } const done = await wait_for_any(page, SELECTORS.done, 60000); await done.click(); await human_delay(3000, 6000); await dismiss_dialogs(page); await prune_tabs(page.browser(), page); console.log(' ✅ Published'); return { file: video.file, success: true, visibility }; } async function process_profile(entry, index, total) { const line = '='.repeat(70); console.log(`\n${line}\n📋 Profile ${index + 1}/${total} — ${entry.channel} (${entry.uuid})`); console.log(` Videos queued: ${entry.videos.length}\n${line}`); const stats = { channel: entry.channel, uuid: entry.uuid, status: 'ok', uploads: [] }; await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(3); let start_data; try { start_data = await octo_call('start', { uuid: entry.uuid, headless: config.headless_mode, debug_port: true, timeout: 120 }); } catch (e) { const body = e.response?.data ? JSON.stringify(e.response.data) : e.message; console.error(`❌ Profile failed to start: ${body}`); return { ...stats, status: 'start_failed' }; } if (!start_data?.ws_endpoint) { console.error('❌ Octo returned no ws_endpoint'); await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); return { ...stats, status: 'no_ws' }; } let browser; try { browser = await puppeteer.connect({ browserWSEndpoint: start_data.ws_endpoint, defaultViewport: null, protocolTimeout: 3600000 }); // The profile starts with a tab already open. Reuse it, otherwise // a second, empty one shows up next to it. const pages = await browser.pages(); const page = pages[0] || await browser.newPage(); await page.setViewport({ width: 1440, height: 900 }); await page.goto('https://studio.youtube.com/', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(1500, 2500); if (page.url().includes('accounts.google.com')) { console.error('❌ Profile is not signed in to Google'); return { ...stats, status: 'not_logged_in' }; } const limit = Math.min(entry.videos.length, config.daily_limit_per_channel); for (let i = 0; i < limit; i++) { try { stats.uploads.push(await upload_one(page, entry.videos[i])); } catch (e) { console.error(`❌ Upload error: ${e.message}`); stats.uploads.push({ file: entry.videos[i].file, success: false, reason: e.message }); } if (i < limit - 1) { const pause = random_range(config.delay_between_uploads.min, config.delay_between_uploads.max); console.log(`⏰ Pause before the next video: ${Math.round(pause)}s`); await sleep(pause); } } if (entry.videos.length > limit) { console.log(`ℹ️ Left in the queue for tomorrow: ${entry.videos.length - limit}`); } } catch (e) { console.error(`❌ Profile processing error: ${e.message}`); stats.status = 'error'; stats.error = e.message; } finally { if (browser) { await prune_tabs(browser); browser.disconnect(); } await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(2); } return stats; } // Validate the queue before launching a browser. An empty title or a wrong // file path would otherwise pass silently: there is nothing to type, no // error is raised, and the video lands on YouTube named after its file. async function validate_queue(queue) { const VISIBILITY = ['PRIVATE', 'UNLISTED', 'PUBLIC']; if (!Array.isArray(queue) || !queue.length) return ['the queue is empty or is not an array']; const problems = []; for (const [i, entry] of queue.entries()) { const where = `entry ${i + 1} (${entry.channel || 'unnamed'})`; if (!entry.uuid) problems.push(`${where}: no profile uuid`); if (!Array.isArray(entry.videos) || !entry.videos.length) { problems.push(`${where}: no videos at all`); continue; } for (const [j, video] of entry.videos.entries()) { const at = `${where}, video ${j + 1}`; const readable = video.file && await fs.access(video.file).then(() => true, () => false); if (!video.file) problems.push(`${at}: no file path`); else if (!readable) problems.push(`${at}: file not found — ${video.file}`); if (!video.title?.trim()) problems.push(`${at}: empty title`); else if (video.title.length > 100) problems.push(`${at}: title over 100 chars (${video.title.length})`); if (video.description?.length > 5000) problems.push(`${at}: description over 5000 chars`); if (video.visibility && !VISIBILITY.includes(video.visibility)) { problems.push(`${at}: visibility "${video.visibility}", allowed: ${VISIBILITY.join(', ')}`); } } } return problems; } (async () => { console.log('🚀 Octo YouTube Bulk Uploader'); const queue = JSON.parse(await fs.readFile(path.join(__dirname, config.queue_file), 'utf8')); const problems = await validate_queue(queue); if (problems.length) { console.error(`\n❌ Queue validation failed (${problems.length}):`); for (const p of problems) console.error(` • ${p}`); console.error('\nFix the queue and run again. No browser was launched.'); process.exit(1); } const total_videos = queue.reduce((sum, e) => sum + e.videos.length, 0); console.log(` ✅ Queue validated — ${queue.length} channels, ${total_videos} videos`); console.log(` Per-channel limit for this run: ${config.daily_limit_per_channel}\n`); const results_dir = path.join(__dirname, config.results_dir); await fs.mkdir(results_dir, { recursive: true }); const all_stats = []; for (let i = 0; i < queue.length; i++) { all_stats.push(await process_profile(queue[i], i, queue.length)); if (i < queue.length - 1) { const pause = random_range(config.delay_between_profiles.min, config.delay_between_profiles.max); console.log(`\n⏰ Pause before the next profile: ${Math.round(pause)}s`); await sleep(pause); } } console.log(`\n${'='.repeat(70)}\n📊 SUMMARY\n${'='.repeat(70)}`); for (const s of all_stats) { const ok = s.uploads.filter(u => u.success).length; console.log(`\n${s.channel} → ${s.status}, succeeded ${ok}/${s.uploads.length}`); for (const u of s.uploads) { console.log(u.success ? ` ✅ ${path.basename(u.file)} → ${u.visibility}` : ` ❌ ${path.basename(u.file)}: ${u.reason}`); } } const report_path = path.join(results_dir, `report_${Date.now()}.json`); await fs.writeFile(report_path, JSON.stringify(all_stats, null, 2), 'utf8'); console.log(`\n📄 Report: ${report_path}\n🎉 Done.`); })();
Checklist antes de ejecutar el proceso en toda la red
Los canales están preparados. Un canal nuevo que recibe veinte vídeos en su primer día parece sospechoso independientemente de cómo se haya llenado. Empieza con dos o tres cargas al día y aumenta gradualmente.
Los proxies son diferentes y no son de datacenter. Una sola IP para veinte canales anula por completo el aislamiento de los perfiles.
Los metadatos no son plantillas idénticas. Descripciones idénticas con los mismos conjuntos de enlaces en toda la red son una de las señales más evidentes de que los canales están relacionados. Genera variaciones.
Los selectores están comprobados. Studio cambia con el tiempo, por lo que antes de una ejecución grande conviene iniciar el script en un perfil con
headless_mode: falsey comprobar visualmente dónde se traba.Un vídeo a la vez por perfil. Subir varios vídeos en paralelo desde un mismo perfil consume el ancho de banda del canal y aumenta la probabilidad de que se produzcan timeouts. Lo recomendable es paralelizar los perfiles, no los vídeos dentro de un mismo perfil.
El contenido no se duplica. Subir el mismo archivo a diez canales puede entrar en conflicto con la política de contenido reutilizado de YouTube. La localización, la edición y la voz en off deberían ser diferentes.
Esquema híbrido: el navegador sube, la API gestiona
Antes se justificaba este esquema por el coste: subir un vídeo costaba 1.600 unidades, mientras que el resto de las operaciones costaban entre 1 y 50. Después de la reducción de diciembre, el argumento del coste desapareció, pero el esquema sigue siendo útil, aunque por otra razón.
Subir vídeos mediante la API desde un proyecto no verificado hace que pasen a locked private, mientras que el bucket de videos.insert está limitado de forma estricta a cien llamadas al día por proyecto y solo puede ampliarse mediante una auditoría. Ninguna de las otras operaciones está sujeta a esas restricciones: se descuentan del fondo general de 10.000 unidades, donde editar metadatos cuesta 50 unidades y leer cuesta una.
De aquí surge una división de responsabilidades. Los archivos se suben mediante automatización del navegador porque así no están sujetos a la regla del cliente no verificado y no consumen el limitado bucket de cargas. Y después entra en juego la API: modifica títulos y descripciones mediante videos.update, establece miniaturas mediante thumbnails.set, organiza los vídeos en playlists y recopila estadísticas. Estas operaciones no requieren auditoría y no existe riesgo de bloqueo de los vídeos porque la carga la realizó el navegador.
El navegador hace lo que resulta arriesgado mediante la API; la API hace lo que es lento y frágil mediante el navegador. Editar masivamente las descripciones de doscientos vídeos mediante la API toma un minuto, mientras que hacerlo mediante la automatización de Studio toma medio día.
Conclusión
La carga masiva de vídeos en YouTube se enfrenta a algo más que los límites de cuota. Desde finales de 2025, la cuota de Data API dejó de ser un problema importante: cien cargas al día por proyecto cubren la mayoría de los casos, y el límite puede ampliarse mediante una auditoría. El bloqueo en modo privado de los vídeos subidos desde proyectos no verificados se resuelve mediante esa misma auditoría o realizando la carga desde el navegador. El límite diario del canal no se puede eludir, por lo que hay que distribuir la carga entre varios canales.
El script de este artículo resuelve este último caso: convierte una red de canales en un único pipeline gestionable, donde la cola se almacena en JSON y cada canal funciona desde su propio perfil aislado con su propio proxy. Se puede ampliar fácilmente en varias direcciones: añadir miniaturas, incorporar subtítulos, programar la publicación en lugar de publicar inmediatamente, o añadir almacenamiento persistente del estado de la cola para que la ejecución siga desde el punto en el que se detuvo el día anterior.
La estabilidad de un sistema así no la determina el script. El script es su parte más sencilla. El sistema funciona mientras los canales parezcan independientes: huellas digitales diferentes, IP distintas, contenido diferente y un ritmo de publicación creíble.
Manténgase en el anonimato, aproveche las ventajas del multicuentas y alcance sus objetivos con el navegador antidetección de mayor calidad del mercado.
¿Te gustaría probar Octo Browser con descuento?
Usa el código promocional OCTOBLOG para obtener un 30% de descuento en cualquier suscripción. Esta oferta solo es válida para nuevos usuarios.
Dos tareas, dos enfoques
Antes de elegir un método de automatización, hay que definir cuál es realmente tu tarea:
Carga masiva de vídeos en un solo canal. Doscientos vídeos para un canal con clases educativas, un archivo de webinars o una recopilación de recortes de podcasts. Aquí el cuello de botella es el límite diario del propio canal, que, como veremos más adelante, no se puede eludir.
Trabajo con varios canales. Versiones localizadas del mismo contenido para diferentes países, canales para distintas ofertas, canales de clientes de una agencia. Aquí la cuestión principal es cómo mantener veinte cuentas de Google lo suficientemente separadas para que YouTube no las vincule entre sí y las bloquee todas en masa.
La primera tarea se resuelve con planificación y publicación programada. La segunda requiere aislamiento de perfiles y automatización. Veamos ambos enfoques.
Funciones nativas de YouTube para la carga masiva
Antes de escribir scripts, comprueba si las funciones integradas de YouTube ya son suficientes para tus necesidades. YouTube Studio permite subir varios archivos a la vez: en el diálogo de carga puedes seleccionar un grupo de vídeos, todos se añadirán como borradores y después podrás editar sus metadatos en masa mediante las casillas de selección. También puedes configurar la publicación programada, por lo que es posible distribuir veinte borradores a lo largo del mes siguiente.
Para trabajar regularmente con un solo canal, esto suele ser suficiente. La automatización empieza a valer la pena cuando hay más de cincuenta vídeos, más de tres canales, o cuando los metadatos se generan mediante programación y se almacenan en un archivo CSV o en una base de datos.
YouTube Data API v3 y sus dos límites
La vía oficial para subir vídeos es el método videos.insert. Admite cargas reanudables, acepta archivos de hasta 256 GB y funciona únicamente mediante OAuth 2.0. Las cuentas de servicio no son adecuadas para subir vídeos en nombre de un canal, lo que hace fracasar desde el primer momento cualquier intento de crear un cargador del lado del servidor sin autorización interactiva: tendrás que obtener manualmente un refresh token al menos una vez por canal.
Pero el problema principal no es la autorización.
La cuota: casi todas las guías sobre este tema están desactualizadas
Si anteriormente buscaste información sobre cargas masivas, casi seguro que te topaste con el cálculo «10.000 unidades divididas entre 1.600 por carga da seis vídeos al día». Ese cálculo ya no es válido, y esto es lo principal que hay que entender en 2026.
Google modificó el modelo de cuotas dos veces. El 4 de diciembre de 2025, el coste de subir un vídeo se redujo de aproximadamente 1.600 unidades a aproximadamente 100. Después, el 1 de junio de 2026, la API pasó a un sistema de cuotas granular: las llamadas videos.insert y search.list se descuentan de sus propios buckets independientes, en lugar de utilizar un fondo común.
La cuota predeterminada actual de un proyecto es la siguiente:
Bucket | Límite predeterminado |
|---|---|
| 100 llamadas al día |
| 100 llamadas al día |
Todos los demás métodos | 10.000 unidades al día en total |
Los buckets son independientes: una vez agotadas las cien cargas, recibirás un 403 de videos.insert, aunque queden 9.000 unidades intactas en el fondo común.
El coste de los métodos dentro del fondo común no ha cambiado:
las lecturas cuestan 1 unidad;
las escrituras, como
videos.updateythumbnails.set, cuestan 50;captions.insertes considerablemente más caro: 400.
Cuidado con la documentación de Google: en el momento de redactar este artículo se contradice a sí misma. La página «Calculadora de cuotas» se actualizó en agosto de 2025 y todavía muestra el antiguo coste de 1.600 unidades para videos.insert. Las fuentes actualizadas son la versión inglesa de la descripción general y la entrada del 1 de junio de 2026 en Revision History. Los límites reales de tu proyecto deben comprobarse siempre en Google Cloud Console: los valores de cuota pueden variar entre proyectos antiguos y nuevos.
Conclusión: cien cargas al día por proyecto ya no son el cuello de botella que obliga a recurrir a soluciones alternativas. Las verdaderas limitaciones están en otro plano.
Primer límite: auditoría y locked private
Hablemos del motivo por el que los cargadores personalizados suelen resultar inútiles, independientemente de los cambios en las cuotas. Todos los vídeos subidos mediante videos.insert desde un proyecto de API no verificado creado después del 28 de julio de 2020 pasan obligatoriamente al modo privado. El propietario del canal recibe un correo indicando que el vídeo está bloqueado como privado porque se ha subido mediante un cliente no verificado.
No existe una vía de apelación para este tipo de bloqueo. La única salida es volver a subir el vídeo mediante un cliente oficial o auditado, o directamente desde el sitio web de YouTube. Es decir, antes de pasar la auditoría, el cargador funciona formalmente, devuelve 200 OK y proporciona el ID del vídeo, pero nadie salvo tú podrá ver el resultado de su trabajo.
Los proyectos creados antes de julio de 2020 no están sujetos a esta regla. Si tienes uno de estos proyectos, estás en una posición favorable. Para todos los demás, será necesario pasar la auditoría o renunciar a las cargas públicas mediante la API.
Segundo límite: el límite diario de cargas del canal
Es la restricción más molesta porque no se puede eludir de ninguna manera. YouTube limita la cantidad de vídeos que un canal puede subir en un periodo de 24 horas y no publica cifras exactas. Se sabe que el límite depende del historial del canal, de la región y de la existencia de strikes, y que se aplica simultáneamente a la versión web, las aplicaciones móviles y la API. Según observaciones de la comunidad, en canales nuevos son aproximadamente 10–20 vídeos, mientras que en canales con una larga trayectoria pueden ser hasta un centenar, pero se trata de observaciones, no de valores documentados.
En la API, este límite aparece como un error independiente uploadLimitExceeded con código 400, y es importante distinguirlo del agotamiento de la cuota del proyecto: el primero corresponde al canal y el segundo al proyecto.
Si alcanzas el límite del canal, tendrás que esperar 24 horas. Cambiar el método de carga o contactar con soporte no ayudará. La única forma efectiva de aumentar la capacidad es no subir más vídeos a un solo canal, sino distribuir la carga entre varios.
Conclusiones para la arquitectura
Con el nuevo modelo de cuotas, la cuestión principal ya no es el número de solicitudes a la API. Cien cargas al día por proyecto son suficientes para la mayoría de las tareas. Sin embargo, subir vídeos mediante la API antes de pasar la auditoría es inútil para el contenido público, y un solo canal no puede escalar verticalmente independientemente del método de carga. Por tanto, solo queda la escalabilidad horizontal: más canales y menos cargas por canal.
Y cuando gestionas veinte canales, necesitas una estrategia estructurada de multicuenta. Google vincula las cuentas de forma más agresiva que la mayoría de las plataformas: mediante la huella del dispositivo, las sesiones de cookies compartidas, los Client Hints coincidentes y las direcciones IP. Veinte canales desde un mismo Chrome y bajo una misma IP terminarán formando un único clúster, que puede ser bloqueado por completo.
Por eso, la arquitectura de la solución es la siguiente:
Navegador antidetecсión Octo Browser: un perfil aislado por canal, cada uno con su propia huella digital y su propio proxy. Los perfiles mantienen sesiones activas de Google, por lo que aquí no sirven los perfiles desechables: se necesitan perfiles persistentes. Es práctico organizarlos en carpetas, de modo que los canales de un mismo proyecto estén agrupados.
Rebrowser-puppeteer: un fork de Puppeteer sin las filtraciones características que delatan la automatización.
Local API de Octo: inicia los perfiles y se conecta a ellos mediante CDP. La descripción completa de los métodos está disponible en la documentación y en la referencia de la API.
Cola de tareas en JSON: un archivo con la lista de perfiles, los vídeos asociados a cada uno y sus metadatos. Así, el script se mantiene universal y todos los datos específicos quedan en el archivo de entrada.
Cómo trabajar con los selectores de Studio
La interfaz de YouTube Studio está construida con componentes web de Polymer, y el enfoque para los selectores aquí es distinto del que utilizamos al hacer scraping de Instagram.
La mala noticia: las clases de Studio se generan dinámicamente y tienen una vida corta. La buena: los elementos clave tienen identificadores y atributos estables que no dependen del idioma de la interfaz. El botón para pasar al siguiente paso es siempre #next-button. Los botones de opción de visibilidad son tp-yt-paper-radio-button[name="PRIVATE"], [name="UNLISTED"], [name="PUBLIC"]. El selector para indicar que el contenido no está destinado a niños es [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]. Ninguno de ellos cambia al cambiar el idioma de la cuenta.
La conclusión práctica es clara: utiliza atributos e identificadores en lugar del texto de los botones. Un script que busque un botón por la palabra «Siguiente» fallará en cuanto se ejecute en un perfil con la interfaz en otro idioma. Un script basado en #next-button funcionará con cualquier idioma, y no será necesario fijar forzosamente la configuración regional mediante ?hl=en.
El segundo truco tiene que ver con la propia carga del archivo. Hacer clic en la zona de arrastrar y soltar no sirve de nada: abrirá el diálogo nativo del sistema operativo, que no se puede cerrar desde el navegador. En su lugar, el archivo se pasa directamente al input[type="file"] oculto mediante uploadFile(). De este modo, el diálogo del sistema operativo ni siquiera aparece.
Script listo para subir vídeos a varios canales
El script recorre secuencialmente los perfiles de Octo, abre Studio en cada uno y sube los vídeos asociados al perfil: completa el título, la descripción y las etiquetas, marca el contenido como no destinado a niños, define la visibilidad y espera a que finalice el procesamiento. Entre las cargas realiza pausas aleatorias, contabiliza las cargas de cada canal y se detiene al alcanzar el límite diario configurado.
Preparación de los perfiles
Crea un perfil de Octo por cada canal y asigna un proxy distinto a cada uno. Es preferible utilizar un proxy residencial o móvil. Antes de trabajar, conviene comprobar su fiabilidad.
Abre cada perfil manualmente e inicia sesión en la cuenta de Google. Entra en Studio, acepta todos los diálogos iniciales y confirma el número de teléfono si es necesario. El script no debería toparse con el proceso de incorporación inicial.
Sube manualmente un vídeo desde cada perfil. Así comprobarás que el canal está preparado para aceptar cargas y, al mismo tiempo, resolverás cualquier cuestión relacionada con la verificación.
Copia los UUID de los perfiles desde el cliente de Octo. Los necesitarás para el archivo de cola.
El archivo de cola
Crea upload_queue.json junto al script. La estructura es la siguiente:
[ { "uuid": "profile_UUID_1", "channel": "channel_name_1", "videos": [ { "file": "file_path_1", "title": "YouTube_video_title", "description": "YouTube_video_description", "tags": ["tag1", "tag2"], "visibility": "PUBLIC" }, { "file": "file_path_2", "title": "YouTube_video_title", "description": "YouTube_video_description", "tags": ["tag1", "tag2"], "visibility": "UNLISTED" } ] }, { "uuid": "profile_UUID_2", "channel": "channel_name_2", "videos": [ { "file": "file_path_3", "title": "YouTube_video_title", "description": "YouTube_video_description", "tags": ["tag1", "tag2"], "visibility": "PRIVATE" } ] } ]
El campo visibility acepta tres valores: PRIVATE, UNLISTED y PUBLIC. Si generas la cola a partir de una hoja de cálculo o una base de datos, basta con exportarla en este mismo formato.
Ejecución
Descarga e instala VS Code.
Descarga e instala Node.js.
Crea una carpeta, por ejemplo
octo_youtube_uploader, y ábrela en VS Code.Crea el archivo
octo_youtube_uploader.jsy pega en él el código del script.Coloca junto a él el archivo
upload_queue.jsoncon tus perfiles y vídeos.Comprueba los parámetros de la variable
config. Los principales sondaily_limit_per_channel(cuántos vídeos subir a un canal en cada ejecución),delay_between_uploads(la pausa entre cargas en segundos), yupload_timeout_min(cuántos minutos esperar a que se procese un vídeo).Abre el terminal y ejecuta
npm i rebrowser-puppeteer axios.Inicia Octo Browser.
Ejecuta el script (Ctrl/Cmd + F5) y sigue el progreso en la consola.
Si VS Code muestra un error al instalar las dependencias, abre PowerShell como administrador, ejecuta
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, confirma y repite la instalación.
Ten en cuenta que la carga y el procesamiento de un vídeo tardan minutos, no segundos. Una ejecución de veinte vídeos puede tardar horas, y es normal. No reduzcas las pausas para acelerar el proceso: son precisamente los intervalos cortos y uniformes entre acciones los que delatan la automatización.
Código del script
const axios = require('axios'); const puppeteer = require('rebrowser-puppeteer'); const fs = require('fs').promises; const path = require('path'); const config = { octo_local_api_base_url: 'http://localhost:58888/api/profiles', headless_mode: false, queue_file: 'upload_queue.json', results_dir: 'youtube_results', daily_limit_per_channel: 8, upload_timeout_min: 40, // These two pauses account for roughly 90% of the total run time. // Shortening them speeds things up but makes the pattern easier to // spot, so treat it as a risk decision rather than an optimization. delay_between_uploads: { min: 180, max: 420 }, delay_between_profiles: { min: 120, max: 300 } }; const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; const SELECTORS = { file: 'input[type="file"]', title: ['#title-textarea #textbox', 'ytcp-social-suggestions-textbox#title-textarea #textbox'], description: ['#description-textarea #textbox', 'ytcp-video-description #textbox'], tags_toggle: '#toggle-button', tags_input: '#tags-container input', not_for_kids: 'tp-yt-paper-radio-button[name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]', next: '#next-button', done: '#done-button', progress: '.progress-label', dialogs: ['#close-button', 'ytcp-dialog #dismiss-button'] }; const random_range = (min, max) => min + Math.random() * (max - min); const sleep = seconds => new Promise(r => setTimeout(r, seconds * 1000)); const human_delay = (min_ms = 80, max_ms = 300) => sleep(random_range(min_ms, max_ms) / 1000); async function check_limits(response) { for (const entry of (response.headers.ratelimit || '').split(',')) { const left = entry.match(/;r=(\d+)/); const reset = entry.match(/;t=(\d+)/); if (left && reset && +left[1] < 5) { const wait = +reset[1] + 1; console.log(`⏳ Octo rate limit reached, waiting ${wait}s`); await sleep(wait); } } } async function octo_call(action, body) { const res = await axios.post(`${config.octo_local_api_base_url}/${action}`, body, { headers: { 'Content-Type': 'application/json' } }); await check_limits(res); return res.data; } // Resolves all selectors in a single round trip instead of one call per // selector: on a 0.5s poll that adds up over a long run. async function wait_for_any(page, selectors, timeout = 30000) { const list = Array.isArray(selectors) ? selectors : [selectors]; const started = Date.now(); while (Date.now() - started < timeout) { const hit = await page.evaluate(sels => sels.find(s => { const el = document.querySelector(s); if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }), list); if (hit) return await page.$(hit); await sleep(0.5); } throw new Error(`None of the selectors matched: ${list.join(' | ')}`); } async function type_into_box(page, handle, text) { await handle.click(); await human_delay(200, 500); await page.keyboard.down(MOD_KEY); await page.keyboard.press('KeyA'); await page.keyboard.up(MOD_KEY); await page.keyboard.press('Backspace'); await human_delay(150, 400); for (const chunk of text.split(/(?<=\s)/)) { await page.keyboard.type(chunk, { delay: random_range(15, 55) }); if (Math.random() < 0.15) await human_delay(200, 700); } } async function dismiss_dialogs(page) { for (const selector of SELECTORS.dialogs) { const handle = await page.$(selector); if (!handle) continue; await handle.click().catch(() => { }); await human_delay(500, 1200); } } // Closes tabs we do not need. Studio opens some on its own, and Octo // persists the session, so leftovers reappear on the next launch. // Without `keep` the last surviving tab is parked on about:blank. async function prune_tabs(browser, keep = null) { try { const pages = await browser.pages(); const survivor = keep || pages[0]; for (const p of pages) { if (p !== survivor) await p.close().catch(() => { }); } if (!keep && survivor) { await survivor.goto('about:blank', { timeout: 15000 }).catch(() => { }); } } catch (e) { console.log(` ⚠️ Tab cleanup failed: ${e.message}`); } } async function wait_for_processing(page, timeout_min) { const deadline = Date.now() + timeout_min * 60 * 1000; let last_report = 0; while (Date.now() < deadline) { const percent = await page.evaluate(sel => { const el = document.querySelector(sel); const m = el && el.textContent.match(/(\d+)\s*%/); return m ? Number(m[1]) : null; }, SELECTORS.progress); // The percentage disappearing from the label means the upload is // done and Studio switched to its processing or checks message. if (percent === null || percent >= 100) return true; if (percent - last_report >= 10) { console.log(` ⬆️ Uploaded ${percent}%`); last_report = percent; } await sleep(5); } return false; } async function fill_metadata(page, video) { const title_box = await wait_for_any(page, SELECTORS.title, 90000); await human_delay(1500, 3000); await type_into_box(page, title_box, video.title); console.log(` ✏️ Title: ${video.title}`); if (video.description?.trim()) { const box = await wait_for_any(page, SELECTORS.description); await type_into_box(page, box, video.description); console.log(' 📝 Description filled in'); } if (video.tags?.length) { const toggle = await page.$(SELECTORS.tags_toggle); if (toggle) { await toggle.click(); await human_delay(1000, 2000); const input = await page.$(SELECTORS.tags_input); if (input) { await input.click(); for (const tag of video.tags) { await page.keyboard.type(tag, { delay: random_range(20, 60) }); await page.keyboard.press('Comma'); await human_delay(300, 800); } console.log(` 🏷 Tags: ${video.tags.join(', ')}`); } } } const not_for_kids = await page.$(SELECTORS.not_for_kids); if (not_for_kids) { await not_for_kids.click(); await human_delay(600, 1400); } } async function upload_one(page, video) { console.log(`\n🎬 ${path.basename(video.file)}`); await page.goto('https://www.youtube.com/upload', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(2000, 4000); await dismiss_dialogs(page); const file_input = await page.waitForSelector(SELECTORS.file, { timeout: 45000 }); await file_input.uploadFile(video.file); console.log(' 📤 File handed to the input, waiting for the metadata dialog'); await fill_metadata(page, video); // Three wizard steps: details, video elements, checks. for (let step = 0; step < 3; step++) { const next = await wait_for_any(page, SELECTORS.next); await next.click(); await human_delay(1500, 3000); } const visibility = video.visibility || 'PRIVATE'; const radio = await wait_for_any(page, `tp-yt-paper-radio-button[name="${visibility}"]`); await radio.click(); console.log(` 👁 Visibility: ${visibility}`); await human_delay(1000, 2000); if (!await wait_for_processing(page, config.upload_timeout_min)) { console.log(' ⚠️ Processing timed out, the video stays a draft'); return { file: video.file, success: false, reason: 'processing_timeout' }; } const done = await wait_for_any(page, SELECTORS.done, 60000); await done.click(); await human_delay(3000, 6000); await dismiss_dialogs(page); await prune_tabs(page.browser(), page); console.log(' ✅ Published'); return { file: video.file, success: true, visibility }; } async function process_profile(entry, index, total) { const line = '='.repeat(70); console.log(`\n${line}\n📋 Profile ${index + 1}/${total} — ${entry.channel} (${entry.uuid})`); console.log(` Videos queued: ${entry.videos.length}\n${line}`); const stats = { channel: entry.channel, uuid: entry.uuid, status: 'ok', uploads: [] }; await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(3); let start_data; try { start_data = await octo_call('start', { uuid: entry.uuid, headless: config.headless_mode, debug_port: true, timeout: 120 }); } catch (e) { const body = e.response?.data ? JSON.stringify(e.response.data) : e.message; console.error(`❌ Profile failed to start: ${body}`); return { ...stats, status: 'start_failed' }; } if (!start_data?.ws_endpoint) { console.error('❌ Octo returned no ws_endpoint'); await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); return { ...stats, status: 'no_ws' }; } let browser; try { browser = await puppeteer.connect({ browserWSEndpoint: start_data.ws_endpoint, defaultViewport: null, protocolTimeout: 3600000 }); // The profile starts with a tab already open. Reuse it, otherwise // a second, empty one shows up next to it. const pages = await browser.pages(); const page = pages[0] || await browser.newPage(); await page.setViewport({ width: 1440, height: 900 }); await page.goto('https://studio.youtube.com/', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(1500, 2500); if (page.url().includes('accounts.google.com')) { console.error('❌ Profile is not signed in to Google'); return { ...stats, status: 'not_logged_in' }; } const limit = Math.min(entry.videos.length, config.daily_limit_per_channel); for (let i = 0; i < limit; i++) { try { stats.uploads.push(await upload_one(page, entry.videos[i])); } catch (e) { console.error(`❌ Upload error: ${e.message}`); stats.uploads.push({ file: entry.videos[i].file, success: false, reason: e.message }); } if (i < limit - 1) { const pause = random_range(config.delay_between_uploads.min, config.delay_between_uploads.max); console.log(`⏰ Pause before the next video: ${Math.round(pause)}s`); await sleep(pause); } } if (entry.videos.length > limit) { console.log(`ℹ️ Left in the queue for tomorrow: ${entry.videos.length - limit}`); } } catch (e) { console.error(`❌ Profile processing error: ${e.message}`); stats.status = 'error'; stats.error = e.message; } finally { if (browser) { await prune_tabs(browser); browser.disconnect(); } await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(2); } return stats; } // Validate the queue before launching a browser. An empty title or a wrong // file path would otherwise pass silently: there is nothing to type, no // error is raised, and the video lands on YouTube named after its file. async function validate_queue(queue) { const VISIBILITY = ['PRIVATE', 'UNLISTED', 'PUBLIC']; if (!Array.isArray(queue) || !queue.length) return ['the queue is empty or is not an array']; const problems = []; for (const [i, entry] of queue.entries()) { const where = `entry ${i + 1} (${entry.channel || 'unnamed'})`; if (!entry.uuid) problems.push(`${where}: no profile uuid`); if (!Array.isArray(entry.videos) || !entry.videos.length) { problems.push(`${where}: no videos at all`); continue; } for (const [j, video] of entry.videos.entries()) { const at = `${where}, video ${j + 1}`; const readable = video.file && await fs.access(video.file).then(() => true, () => false); if (!video.file) problems.push(`${at}: no file path`); else if (!readable) problems.push(`${at}: file not found — ${video.file}`); if (!video.title?.trim()) problems.push(`${at}: empty title`); else if (video.title.length > 100) problems.push(`${at}: title over 100 chars (${video.title.length})`); if (video.description?.length > 5000) problems.push(`${at}: description over 5000 chars`); if (video.visibility && !VISIBILITY.includes(video.visibility)) { problems.push(`${at}: visibility "${video.visibility}", allowed: ${VISIBILITY.join(', ')}`); } } } return problems; } (async () => { console.log('🚀 Octo YouTube Bulk Uploader'); const queue = JSON.parse(await fs.readFile(path.join(__dirname, config.queue_file), 'utf8')); const problems = await validate_queue(queue); if (problems.length) { console.error(`\n❌ Queue validation failed (${problems.length}):`); for (const p of problems) console.error(` • ${p}`); console.error('\nFix the queue and run again. No browser was launched.'); process.exit(1); } const total_videos = queue.reduce((sum, e) => sum + e.videos.length, 0); console.log(` ✅ Queue validated — ${queue.length} channels, ${total_videos} videos`); console.log(` Per-channel limit for this run: ${config.daily_limit_per_channel}\n`); const results_dir = path.join(__dirname, config.results_dir); await fs.mkdir(results_dir, { recursive: true }); const all_stats = []; for (let i = 0; i < queue.length; i++) { all_stats.push(await process_profile(queue[i], i, queue.length)); if (i < queue.length - 1) { const pause = random_range(config.delay_between_profiles.min, config.delay_between_profiles.max); console.log(`\n⏰ Pause before the next profile: ${Math.round(pause)}s`); await sleep(pause); } } console.log(`\n${'='.repeat(70)}\n📊 SUMMARY\n${'='.repeat(70)}`); for (const s of all_stats) { const ok = s.uploads.filter(u => u.success).length; console.log(`\n${s.channel} → ${s.status}, succeeded ${ok}/${s.uploads.length}`); for (const u of s.uploads) { console.log(u.success ? ` ✅ ${path.basename(u.file)} → ${u.visibility}` : ` ❌ ${path.basename(u.file)}: ${u.reason}`); } } const report_path = path.join(results_dir, `report_${Date.now()}.json`); await fs.writeFile(report_path, JSON.stringify(all_stats, null, 2), 'utf8'); console.log(`\n📄 Report: ${report_path}\n🎉 Done.`); })();
Checklist antes de ejecutar el proceso en toda la red
Los canales están preparados. Un canal nuevo que recibe veinte vídeos en su primer día parece sospechoso independientemente de cómo se haya llenado. Empieza con dos o tres cargas al día y aumenta gradualmente.
Los proxies son diferentes y no son de datacenter. Una sola IP para veinte canales anula por completo el aislamiento de los perfiles.
Los metadatos no son plantillas idénticas. Descripciones idénticas con los mismos conjuntos de enlaces en toda la red son una de las señales más evidentes de que los canales están relacionados. Genera variaciones.
Los selectores están comprobados. Studio cambia con el tiempo, por lo que antes de una ejecución grande conviene iniciar el script en un perfil con
headless_mode: falsey comprobar visualmente dónde se traba.Un vídeo a la vez por perfil. Subir varios vídeos en paralelo desde un mismo perfil consume el ancho de banda del canal y aumenta la probabilidad de que se produzcan timeouts. Lo recomendable es paralelizar los perfiles, no los vídeos dentro de un mismo perfil.
El contenido no se duplica. Subir el mismo archivo a diez canales puede entrar en conflicto con la política de contenido reutilizado de YouTube. La localización, la edición y la voz en off deberían ser diferentes.
Esquema híbrido: el navegador sube, la API gestiona
Antes se justificaba este esquema por el coste: subir un vídeo costaba 1.600 unidades, mientras que el resto de las operaciones costaban entre 1 y 50. Después de la reducción de diciembre, el argumento del coste desapareció, pero el esquema sigue siendo útil, aunque por otra razón.
Subir vídeos mediante la API desde un proyecto no verificado hace que pasen a locked private, mientras que el bucket de videos.insert está limitado de forma estricta a cien llamadas al día por proyecto y solo puede ampliarse mediante una auditoría. Ninguna de las otras operaciones está sujeta a esas restricciones: se descuentan del fondo general de 10.000 unidades, donde editar metadatos cuesta 50 unidades y leer cuesta una.
De aquí surge una división de responsabilidades. Los archivos se suben mediante automatización del navegador porque así no están sujetos a la regla del cliente no verificado y no consumen el limitado bucket de cargas. Y después entra en juego la API: modifica títulos y descripciones mediante videos.update, establece miniaturas mediante thumbnails.set, organiza los vídeos en playlists y recopila estadísticas. Estas operaciones no requieren auditoría y no existe riesgo de bloqueo de los vídeos porque la carga la realizó el navegador.
El navegador hace lo que resulta arriesgado mediante la API; la API hace lo que es lento y frágil mediante el navegador. Editar masivamente las descripciones de doscientos vídeos mediante la API toma un minuto, mientras que hacerlo mediante la automatización de Studio toma medio día.
Conclusión
La carga masiva de vídeos en YouTube se enfrenta a algo más que los límites de cuota. Desde finales de 2025, la cuota de Data API dejó de ser un problema importante: cien cargas al día por proyecto cubren la mayoría de los casos, y el límite puede ampliarse mediante una auditoría. El bloqueo en modo privado de los vídeos subidos desde proyectos no verificados se resuelve mediante esa misma auditoría o realizando la carga desde el navegador. El límite diario del canal no se puede eludir, por lo que hay que distribuir la carga entre varios canales.
El script de este artículo resuelve este último caso: convierte una red de canales en un único pipeline gestionable, donde la cola se almacena en JSON y cada canal funciona desde su propio perfil aislado con su propio proxy. Se puede ampliar fácilmente en varias direcciones: añadir miniaturas, incorporar subtítulos, programar la publicación en lugar de publicar inmediatamente, o añadir almacenamiento persistente del estado de la cola para que la ejecución siga desde el punto en el que se detuvo el día anterior.
La estabilidad de un sistema así no la determina el script. El script es su parte más sencilla. El sistema funciona mientras los canales parezcan independientes: huellas digitales diferentes, IP distintas, contenido diferente y un ritmo de publicación creíble.
Mantente al día de las últimas noticias sobre Octo Browser
Al hacer clic en el botón, aceptas nuestra Política de privacidad.
Mantente al día de las últimas noticias sobre Octo Browser
Al hacer clic en el botón, aceptas nuestra Política de privacidad.
Mantente al día de las últimas noticias sobre Octo Browser
Al hacer clic en el botón, aceptas nuestra Política de privacidad.

Únete a Octo Browser ahora
O contacta al servicio al cliente en cualquier momento si tienes alguna pregunta.

Únete a Octo Browser ahora
O contacta al servicio al cliente en cualquier momento si tienes alguna pregunta.
Únete a Octo Browser ahora
O contacta al servicio al cliente en cualquier momento si tienes alguna pregunta.