Uploads em massa de vídeos do YouTube: cotas de API de dados e automação do Studio

Uploads em massa de vídeos do YouTube: cotas de API de dados e automação do Studio
Anton Misiul
Anton Misiul

Customer Service Specialist

O upload de vídeos em massa no YouTube rapidamente esbarra nas limitações da plataforma. Quando você está lidando com centenas de vídeos e múltiplos canais, o trabalho manual através do YouTube Studio torna-se ineficiente, enquanto a transição para a YouTube Data API introduz suas próprias limitações, desde cotas de upload até restrições na visibilidade dos vídeos.

Neste artigo, veremos as principais maneiras de realizar uploads de vídeos em massa, as limitações do YouTube e formas de construir tal sistema. Também recalcularemos as cotas atuais da Data API separadamente: no final de 2025, o Google alterou o custo dos uploads de vídeos e, em junho de 2026, começou a migrar para um modelo de cotas mais granular, de modo que muitos guias antigos já estão desatualizados. No final, criaremos um script em Node.js funcional para fazer o upload de vídeos em múltiplos canais através de perfis isolados do Octo Browser.

Índice

Mantenha o anonimato, aproveite o recurso multiconta e alcance seus objetivos com o melhor navegador antidetecção do mercado.

Você gostaria de experimentar o Octo Browser com desconto?
Use o código promocional OCTOBLOG para obter 30% de desconto em qualquer assinatura. Esta oferta é válida apenas para novos usuários.

Duas tarefas, duas abordagens

Antes de escolher um método de automação, determine o que você realmente está tentando realizar:

  • Uploads de vídeo em massa para um canal. Duzentos vídeos para um canal com palestras educativas, um arquivo de webinar, clipes de podcast. Aqui, o gargalo é o próprio limite diário do canal, que, como veremos a seguir, não pode ser contornado.

  • Trabalhar com vários canais. Versões localizadas do mesmo conteúdo para diferentes países, canais para ofertas distintas, canais de clientes gerenciados por uma agência. Aqui, a principal questão é como manter vinte contas do Google separadas o suficiente para que o YouTube não as associe e bana todas de uma vez.

A primeira tarefa é resolvida por meio de planejamento e publicação agendada. A segunda requer isolamento de perfil e automação. Vamos analisar ambas as abordagens.

Recursos nativos do YouTube para uploads em massa

Antes de escrever scripts, verifique se a funcionalidade nativa do YouTube já é suficiente para as suas necessidades. O YouTube Studio aceita vários arquivos de uma vez: na janela de upload, você pode selecionar um lote de vídeos, todos eles serão adicionados como rascunhos, e você poderá editar os metadados em massa marcando várias caixas de seleção. A publicação agendada também pode ser configurada por lá, de modo que vinte rascunhos podem ser programados para o mês seguinte.

Para um trabalho regular com um único canal, isso costuma ser suficiente. A automação começa a valer a pena quando você tem mais de cinquenta vídeos, mais de três canais, ou quando os metadados são gerados programaticamente e armazenados em um arquivo CSV ou banco de dados.

YouTube Data API v3 e seus dois limites máximos

A forma oficial de enviar vídeos é o método videos.insert. Ele suporta uploads retomáveis, aceita arquivos de até 256 GB e funciona apenas via OAuth 2.0. Contas de serviço não são adequadas para fazer upload em nome de um canal, o que quebra a primeiríssima tentativa de construir um uploader no lado do servidor sem autorização interativa: você terá que obter um refresh token manualmente pelo menos uma vez por canal.

No entanto, a autorização não é o principal problema.

Cota: quase todos os guias sobre o assunto estão desatualizados

Se você já pesquisou sobre upload em massa antes, certamente viu o seguinte cálculo: "10.000 unidades divididas por 1.600 por upload é igual a seis vídeos por dia." Esse cálculo não é mais válido, e isso é o mais importante a entender em 2026.

O Google alterou o modelo de cotas duas vezes. Em 4 de dezembro de 2025, o custo do upload de um vídeo foi reduzido de aproximadamente 1.600 unidades para cerca de 100. Depois, em 1 de junho de 2026, a API passou a usar um sistema de cotas granular: as chamadas para videos.insert e search.list são deduzidas de seus próprios limites separados, em vez de um pool compartilhado único.

A cota padrão atual do projeto se parece com isto:

Limite

Limite padrão

videos.insert (upload de vídeo)

100 chamadas por dia

search.list (pesquisa)

100 chamadas por dia

Todos os outros métodos

10.000 unidades por dia combinadas

Os limites são independentes: uma vez que você tenha usado seus cem uploads, você receberá um erro 403 de videos.insert, mesmo que 9.000 unidades permaneçam intactas no pool geral.

O custo dos métodos dentro do pool geral não mudou:

  • as leituras custam 1 unidade;

  • gravações como videos.update e thumbnails.set custam 50;

  • captions.insert é significativamente mais caro, custando 400.

Tenha cuidado com a documentação do Google: no momento em que este artigo foi escrito, ela se contradizia. A página do "Calculador de cotas" foi atualizada em agosto de 2025 e ainda exibe o custo antigo de 1.600 unidades para o videos.insert. A visão geral em inglês e a entrada de 1 de junho de 2026 no Histórico de revisões são as fontes atualizadas. Você deve sempre verificar os limites reais do seu projeto no Google Cloud Console: os valores de cota podem diferir entre projetos mais antigos e mais novos.

Conclusão: cem uploads por dia por projeto não é mais o gargalo que exige soluções alternativas. As limitações reais estão em outro lugar.

Primeiro limite: auditoria e bloqueio como privado

Vamos falar sobre o problema que mais frequentemente torna inúteis os uploaders personalizados, independentemente das alterações de cota. Todos os vídeos enviados com videos.insert a partir de um projeto de API não verificado criado após 28 de julho de 2020 são alterados obrigatoriamente para privados. O proprietário do canal recebe um e-mail informando que o vídeo foi bloqueado como privado porque foi enviado por meio de um cliente não verificado.

Isso não pode ser contestado. A única saída é reenviar o vídeo por meio de um cliente oficial ou auditado, ou pelo site do YouTube. Portanto, até que a auditoria seja aprovada, o uploader funciona formalmente, retorna 200 OK e fornece um ID de vídeo, mas ninguém além de você poderá ver o resultado do trabalho.

Projetos criados antes de julho de 2020 não estão sujeitos a essa regra. Se você possui um projeto assim, está com sorte. Todos os outros terão que passar pela auditoria ou desistir dos uploads públicos via API.

Segundo limite: o limite diário de upload do canal

Essa é a restrição mais incômoda porque não há absolutamente nenhuma forma de contorná-la. O YouTube limita quantos vídeos um canal pode enviar em um período de 24 horas e não publica números exatos. Sabemos que o limite depende do histórico do canal, da região e se ele possui avisos, e que se aplica simultaneamente à versão web, aplicativos móveis e API. Com base em observações da comunidade, o limite é de aproximadamente 10 a 20 vídeos para canais novos e até cem para canais antigos, mas essas são apenas observações, não valores documentados.

Na API, esse limite aparece como um erro separado uploadLimitExceeded com código 400, e é importante distingui-lo da cota de projeto esgotada: o primeiro se aplica ao canal, enquanto o segundo se aplica ao projeto.

Se você atingir o limite do canal, terá que aguardar 24 horas. Alterar o método de upload ou entrar em contato com o suporte não ajudará. A única maneira eficaz de aumentar a capacidade não é enviar mais para um único canal, mas sim distribuir o volume de trabalho em vários canais.

Conclusões arquiteturais

Sob o novo modelo de cotas, a principal questão já não é a quantidade de requisições de API. Cem uploads por dia por projeto são suficientes para a maioria das tarefas. No entanto, fazer uploads pela API antes de passar pela auditoria é inútil para conteúdo público, e um único canal não pode ser dimensionado verticalmente, independentemente do método de upload. Resta apenas o dimensionamento horizontal: mais canais, menos uploads por canal.

E quando você está gerenciando vinte canais, precisa de uma estratégia estruturada de multicontas. O Google associa contas de forma mais agressiva do que a maioria das plataformas: por meio de impressões digitais de dispositivos, sessões de cookie compartilhadas, Client Hints correspondentes e endereços IP. Vinte canais a partir de uma instância do Chrome sob o mesmo IP serão vinculados em um único cluster, que poderá então ser bloqueado por inteiro.

Portanto, a arquitetura da solução se parece com isto:

  1. Octo Browser, um navegador antidetecção para multicontas: um perfil isolado por canal, cada um com sua própria impressão digital e proxy. Os perfis contêm sessões ativas do Google, de modo que perfis temporários não são adequados aqui; você precisa de perfis persistentes. Eles podem ser convenientemente organizados em pastas para que canais pertencentes ao mesmo projeto fiquem agrupados.

  2. Rebrowser-puppeteer: um fork do Puppeteer sem os vazamentos característicos de automação.

  3. Octo Local API: inicia perfis e se conecta a eles via CDP. Uma descrição detalhada dos métodos está disponível na documentação e na referência da API.

  4. Fila de tarefas em JSON: um arquivo que contém a lista de perfis, seus vídeos associados e metadados. Isso mantém o script universal, enquanto todas as especificações são extraídas dos dados de entrada.

Como trabalhar com seletores do Studio

A interface do YouTube Studio é construída com componentes web Polymer, e a estratégia de seletores aqui difere da que usamos para coletar dados do Instagram.

A má notícia: as classes no Studio são geradas e têm vida curta. A boa notícia: os elementos-chave têm IDs e atributos estáveis que não estão vinculados ao idioma da interface. O botão para avançar para a próxima etapa é sempre #next-button. Os botões de opção de visibilidade são tp-yt-paper-radio-button[name="PRIVATE"], [name="UNLISTED"], [name="PUBLIC"]. A opção para conteúdo destinado a crianças é [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]. Nada disso muda quando o idioma da conta é alterado.

O aprendizado prático: confie em atributos e IDs em vez do texto dos botões. Um script que procura por um botão contendo a palavra "Avançar" deixará de funcionar assim que for executado em um perfil com um idioma de interface diferente. Um script que usa #next-button funcionará em qualquer idioma, e não há necessidade de forçar o idioma por meio de ?hl=en.

O segundo truque diz respeito ao próprio upload de arquivos. Clicar na área de arrastar e soltar não faz sentido: isso abre o diálogo nativo do sistema operacional, que não pode ser fechado pelo navegador. Em vez disso, o arquivo é passado diretamente para o input[type="file"] oculto por meio de uploadFile(). O diálogo do SO não chega a aparecer nesse caso.

Script pronto para upload de vídeos em vários canais

O script percorre sequencialmente os perfis do Octo, abre o Studio em cada um deles e envia os vídeos atribuídos àquele perfil: preenche o título, a descrição e as tags, marca o conteúdo como não destinado a crianças, define a visibilidade e aguarda a conclusão do processamento. Entre os uploads, ele faz pausas aleatórias, contabiliza os uploads para cada canal e para assim que atinge o limite diário configurado.

Preparação dos perfis

  1. Crie um perfil do Octo para cada canal e atribua a cada perfil o seu próprio proxy. Um proxy residencial ou móvel é preferível. Antes de usá-lo, você deve verificar sua confiabilidade.

  2. Abra cada perfil manualmente e faça login na conta correspondente do Google. Acesse o Studio, aceite todos os avisos iniciais e confirme o número de telefone, se necessário. O script não deve se deparar com o fluxo de introdução (onboarding).

  3. Faça o upload de um vídeo manualmente a partir de cada perfil. Isso confirma que o canal está pronto para aceitar uploads e também resolve quaisquer problemas de verificação.

  4. Copie os UUIDs dos perfis do Octo. Você precisará deles para o arquivo de fila.

Arquivo de fila

Crie o arquivo upload_queue.json ao lado do script com a seguinte estrutura:

[
  {
    "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"
      }
    ]
  }
]

O campo visibility aceita três valores: PRIVATE, UNLISTED e PUBLIC. Se você gerar a fila a partir de uma planilha ou banco de dados, basta exportá-la no mesmo formato.

Executando o script

  1. Baixe e instale o VS Code.

  2. Baixe e instale o Node.js.

  3. Crie uma pasta, por exemplo octo_youtube_uploader, e abra-a no VS Code.

  4. Crie o arquivo octo_youtube_uploader.js e cole o código do script nele.

  5. Coloque o arquivo upload_queue.json com seus perfis e vídeos ao lado dele.

  6. Verifique os parâmetros na variável config. Os principais são daily_limit_per_channel (quantos vídeos enviar por canal a cada execução), delay_between_uploads (a pausa entre uploads em segundos) e upload_timeout_min (quantos minutos esperar para a conclusão do processamento do vídeo).

  7. Abra o terminal e execute npm i rebrowser-puppeteer axios.

  8. Inicie o Octo Browser.

  9. Execute o script (Ctrl/Cmd + F5) e acompanhe o progresso no console.

Se o VS Code exibir um erro ao instalar as dependências, abra o PowerShell como administrador, execute Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, confirme e repita a instalação.

Lembre-se de que o upload e processamento de um vídeo leva minutos, não segundos. Uma execução com vinte vídeos pode levar horas, e isso é normal. Não encurte as pausas para acelerar o processo: intervalos curtos e consistentes entre as ações são exatamente o que revela padrões de automação.

Código do 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.`);
})();

Lista de verificação antes de rodar toda a rede

  1. Os canais foram devidamente preparados. Um canal novo que recebe vinte vídeos no primeiro dia parece suspeito, independentemente de como é populado. Comece com dois ou três uploads por dia e cresça o canal gradualmente.

  2. Os proxies são diferentes e não pertencem a datacenters. Um único endereço IP para vinte canais anula o isolamento do perfil.

  3. Os metadados não são padronizados. Descrições idênticas com conjuntos idênticos de links em toda a rede são um dos sinais mais óbvios de que os canais estão conectados. Gere variações.

  4. Os seletores foram testados. O Studio muda com o tempo, por isso, antes de uma grande execução, execute o script em um perfil com headless_mode: false e inspecione visualmente onde ele pode falhar.

  5. Um vídeo por vez por perfil. Uploads paralelos de vários vídeos a partir do mesmo perfil são limitados pela largura de banda do canal e aumentam a chance de timeouts. Paralelize perfis, não vídeos dentro de um perfil.

  6. O conteúdo não é duplicado. Enviar o mesmo arquivo para dez canais pode se enquadrar na política de conteúdo reutilizado do YouTube. A localização, edição e narração devem diferir.

Uma abordagem híbrida: uploads pelo navegador, gerenciamento por API

Essa abordagem costumava ser justificada pelo seu custo: o upload custava 1.600 unidades, enquanto tudo o mais custava entre 1 e 50. Após a redução de preço em dezembro, o argumento do custo desapareceu, mas a abordagem em si continua útil, simplesmente por um motivo diferente.

Uploads via API a partir de um projeto não verificado resultam em vídeos privados bloqueados, enquanto o limite de chamadas de videos.insert é de cem por dia por projeto e só pode ser expandido por meio de auditoria. Todas as outras operações não estão sujeitas a nenhuma das duas restrições: são deduzidas do pool geral de 10.000 unidades, onde a edição de metadados custa 50 unidades e a leitura custa uma.

Isso leva a uma divisão de responsabilidades. Os arquivos são enviados por meio de automação do navegador porque isso evita a regra do cliente não verificado e não consome o escasso limite de upload da API. Depois disso, a API assume o controle: edita títulos e descrições via videos.update, define miniaturas via thumbnails.set, organiza os vídeos em playlists e coleta estatísticas. Nenhuma auditoria é necessária para essas operações e não há risco de os vídeos serem bloqueados, pois o upload em si foi realizado pelo navegador.

O navegador faz o que é arriscado pela API; a API faz o que é lento e frágil pelo navegador. Editar descrições em massa em duzentos vídeos via API leva um minuto, enquanto fazer isso via automação no Studio leva meio dia.

Conclusão

Os uploads do YouTube em massa enfrentam mais barreiras do que apenas os limites de cotas. Desde o final de 2025, a cota da Data API praticamente deixou de ser um problema: cem uploads por dia por projeto cobrem a maioria dos casos de uso, e o limite pode ser expandido por meio de uma auditoria. O problema do bloqueio como privado para vídeos enviados de projetos não verificados é resolvido por essa mesma auditoria ou fazendo o upload dos vídeos no navegador. O limite diário de upload do canal simplesmente não pode ser contornado, de modo que a carga de trabalho precisa ser distribuída por vários canais.

O script apresentado neste artigo lida com o último caso: ele transforma uma rede de canais em um único fluxo gerenciável, onde a fila reside em um arquivo JSON e cada canal opera a partir de seu próprio perfil isolado com seu próprio proxy. Ele pode ser expandido de várias formas óbvias: atribuir miniaturas, adicionar legendas, programar a publicação em vez de publicar imediatamente ou adicionar um estado de fila persistente para que uma execução continue de onde parou no dia anterior.

A resiliência de um sistema desse tipo não é determinada pelo script, que é a sua parte mais simples. O sistema funciona desde que os canais pareçam independentes: impressões digitais diferentes, endereços IP diferentes, conteúdos diferentes e um ritmo de publicação plausível.

Mantenha o anonimato, aproveite o recurso multiconta e alcance seus objetivos com o melhor navegador antidetecção do mercado.

Você gostaria de experimentar o Octo Browser com desconto?
Use o código promocional OCTOBLOG para obter 30% de desconto em qualquer assinatura. Esta oferta é válida apenas para novos usuários.

Duas tarefas, duas abordagens

Antes de escolher um método de automação, determine o que você realmente está tentando realizar:

  • Uploads de vídeo em massa para um canal. Duzentos vídeos para um canal com palestras educativas, um arquivo de webinar, clipes de podcast. Aqui, o gargalo é o próprio limite diário do canal, que, como veremos a seguir, não pode ser contornado.

  • Trabalhar com vários canais. Versões localizadas do mesmo conteúdo para diferentes países, canais para ofertas distintas, canais de clientes gerenciados por uma agência. Aqui, a principal questão é como manter vinte contas do Google separadas o suficiente para que o YouTube não as associe e bana todas de uma vez.

A primeira tarefa é resolvida por meio de planejamento e publicação agendada. A segunda requer isolamento de perfil e automação. Vamos analisar ambas as abordagens.

Recursos nativos do YouTube para uploads em massa

Antes de escrever scripts, verifique se a funcionalidade nativa do YouTube já é suficiente para as suas necessidades. O YouTube Studio aceita vários arquivos de uma vez: na janela de upload, você pode selecionar um lote de vídeos, todos eles serão adicionados como rascunhos, e você poderá editar os metadados em massa marcando várias caixas de seleção. A publicação agendada também pode ser configurada por lá, de modo que vinte rascunhos podem ser programados para o mês seguinte.

Para um trabalho regular com um único canal, isso costuma ser suficiente. A automação começa a valer a pena quando você tem mais de cinquenta vídeos, mais de três canais, ou quando os metadados são gerados programaticamente e armazenados em um arquivo CSV ou banco de dados.

YouTube Data API v3 e seus dois limites máximos

A forma oficial de enviar vídeos é o método videos.insert. Ele suporta uploads retomáveis, aceita arquivos de até 256 GB e funciona apenas via OAuth 2.0. Contas de serviço não são adequadas para fazer upload em nome de um canal, o que quebra a primeiríssima tentativa de construir um uploader no lado do servidor sem autorização interativa: você terá que obter um refresh token manualmente pelo menos uma vez por canal.

No entanto, a autorização não é o principal problema.

Cota: quase todos os guias sobre o assunto estão desatualizados

Se você já pesquisou sobre upload em massa antes, certamente viu o seguinte cálculo: "10.000 unidades divididas por 1.600 por upload é igual a seis vídeos por dia." Esse cálculo não é mais válido, e isso é o mais importante a entender em 2026.

O Google alterou o modelo de cotas duas vezes. Em 4 de dezembro de 2025, o custo do upload de um vídeo foi reduzido de aproximadamente 1.600 unidades para cerca de 100. Depois, em 1 de junho de 2026, a API passou a usar um sistema de cotas granular: as chamadas para videos.insert e search.list são deduzidas de seus próprios limites separados, em vez de um pool compartilhado único.

A cota padrão atual do projeto se parece com isto:

Limite

Limite padrão

videos.insert (upload de vídeo)

100 chamadas por dia

search.list (pesquisa)

100 chamadas por dia

Todos os outros métodos

10.000 unidades por dia combinadas

Os limites são independentes: uma vez que você tenha usado seus cem uploads, você receberá um erro 403 de videos.insert, mesmo que 9.000 unidades permaneçam intactas no pool geral.

O custo dos métodos dentro do pool geral não mudou:

  • as leituras custam 1 unidade;

  • gravações como videos.update e thumbnails.set custam 50;

  • captions.insert é significativamente mais caro, custando 400.

Tenha cuidado com a documentação do Google: no momento em que este artigo foi escrito, ela se contradizia. A página do "Calculador de cotas" foi atualizada em agosto de 2025 e ainda exibe o custo antigo de 1.600 unidades para o videos.insert. A visão geral em inglês e a entrada de 1 de junho de 2026 no Histórico de revisões são as fontes atualizadas. Você deve sempre verificar os limites reais do seu projeto no Google Cloud Console: os valores de cota podem diferir entre projetos mais antigos e mais novos.

Conclusão: cem uploads por dia por projeto não é mais o gargalo que exige soluções alternativas. As limitações reais estão em outro lugar.

Primeiro limite: auditoria e bloqueio como privado

Vamos falar sobre o problema que mais frequentemente torna inúteis os uploaders personalizados, independentemente das alterações de cota. Todos os vídeos enviados com videos.insert a partir de um projeto de API não verificado criado após 28 de julho de 2020 são alterados obrigatoriamente para privados. O proprietário do canal recebe um e-mail informando que o vídeo foi bloqueado como privado porque foi enviado por meio de um cliente não verificado.

Isso não pode ser contestado. A única saída é reenviar o vídeo por meio de um cliente oficial ou auditado, ou pelo site do YouTube. Portanto, até que a auditoria seja aprovada, o uploader funciona formalmente, retorna 200 OK e fornece um ID de vídeo, mas ninguém além de você poderá ver o resultado do trabalho.

Projetos criados antes de julho de 2020 não estão sujeitos a essa regra. Se você possui um projeto assim, está com sorte. Todos os outros terão que passar pela auditoria ou desistir dos uploads públicos via API.

Segundo limite: o limite diário de upload do canal

Essa é a restrição mais incômoda porque não há absolutamente nenhuma forma de contorná-la. O YouTube limita quantos vídeos um canal pode enviar em um período de 24 horas e não publica números exatos. Sabemos que o limite depende do histórico do canal, da região e se ele possui avisos, e que se aplica simultaneamente à versão web, aplicativos móveis e API. Com base em observações da comunidade, o limite é de aproximadamente 10 a 20 vídeos para canais novos e até cem para canais antigos, mas essas são apenas observações, não valores documentados.

Na API, esse limite aparece como um erro separado uploadLimitExceeded com código 400, e é importante distingui-lo da cota de projeto esgotada: o primeiro se aplica ao canal, enquanto o segundo se aplica ao projeto.

Se você atingir o limite do canal, terá que aguardar 24 horas. Alterar o método de upload ou entrar em contato com o suporte não ajudará. A única maneira eficaz de aumentar a capacidade não é enviar mais para um único canal, mas sim distribuir o volume de trabalho em vários canais.

Conclusões arquiteturais

Sob o novo modelo de cotas, a principal questão já não é a quantidade de requisições de API. Cem uploads por dia por projeto são suficientes para a maioria das tarefas. No entanto, fazer uploads pela API antes de passar pela auditoria é inútil para conteúdo público, e um único canal não pode ser dimensionado verticalmente, independentemente do método de upload. Resta apenas o dimensionamento horizontal: mais canais, menos uploads por canal.

E quando você está gerenciando vinte canais, precisa de uma estratégia estruturada de multicontas. O Google associa contas de forma mais agressiva do que a maioria das plataformas: por meio de impressões digitais de dispositivos, sessões de cookie compartilhadas, Client Hints correspondentes e endereços IP. Vinte canais a partir de uma instância do Chrome sob o mesmo IP serão vinculados em um único cluster, que poderá então ser bloqueado por inteiro.

Portanto, a arquitetura da solução se parece com isto:

  1. Octo Browser, um navegador antidetecção para multicontas: um perfil isolado por canal, cada um com sua própria impressão digital e proxy. Os perfis contêm sessões ativas do Google, de modo que perfis temporários não são adequados aqui; você precisa de perfis persistentes. Eles podem ser convenientemente organizados em pastas para que canais pertencentes ao mesmo projeto fiquem agrupados.

  2. Rebrowser-puppeteer: um fork do Puppeteer sem os vazamentos característicos de automação.

  3. Octo Local API: inicia perfis e se conecta a eles via CDP. Uma descrição detalhada dos métodos está disponível na documentação e na referência da API.

  4. Fila de tarefas em JSON: um arquivo que contém a lista de perfis, seus vídeos associados e metadados. Isso mantém o script universal, enquanto todas as especificações são extraídas dos dados de entrada.

Como trabalhar com seletores do Studio

A interface do YouTube Studio é construída com componentes web Polymer, e a estratégia de seletores aqui difere da que usamos para coletar dados do Instagram.

A má notícia: as classes no Studio são geradas e têm vida curta. A boa notícia: os elementos-chave têm IDs e atributos estáveis que não estão vinculados ao idioma da interface. O botão para avançar para a próxima etapa é sempre #next-button. Os botões de opção de visibilidade são tp-yt-paper-radio-button[name="PRIVATE"], [name="UNLISTED"], [name="PUBLIC"]. A opção para conteúdo destinado a crianças é [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]. Nada disso muda quando o idioma da conta é alterado.

O aprendizado prático: confie em atributos e IDs em vez do texto dos botões. Um script que procura por um botão contendo a palavra "Avançar" deixará de funcionar assim que for executado em um perfil com um idioma de interface diferente. Um script que usa #next-button funcionará em qualquer idioma, e não há necessidade de forçar o idioma por meio de ?hl=en.

O segundo truque diz respeito ao próprio upload de arquivos. Clicar na área de arrastar e soltar não faz sentido: isso abre o diálogo nativo do sistema operacional, que não pode ser fechado pelo navegador. Em vez disso, o arquivo é passado diretamente para o input[type="file"] oculto por meio de uploadFile(). O diálogo do SO não chega a aparecer nesse caso.

Script pronto para upload de vídeos em vários canais

O script percorre sequencialmente os perfis do Octo, abre o Studio em cada um deles e envia os vídeos atribuídos àquele perfil: preenche o título, a descrição e as tags, marca o conteúdo como não destinado a crianças, define a visibilidade e aguarda a conclusão do processamento. Entre os uploads, ele faz pausas aleatórias, contabiliza os uploads para cada canal e para assim que atinge o limite diário configurado.

Preparação dos perfis

  1. Crie um perfil do Octo para cada canal e atribua a cada perfil o seu próprio proxy. Um proxy residencial ou móvel é preferível. Antes de usá-lo, você deve verificar sua confiabilidade.

  2. Abra cada perfil manualmente e faça login na conta correspondente do Google. Acesse o Studio, aceite todos os avisos iniciais e confirme o número de telefone, se necessário. O script não deve se deparar com o fluxo de introdução (onboarding).

  3. Faça o upload de um vídeo manualmente a partir de cada perfil. Isso confirma que o canal está pronto para aceitar uploads e também resolve quaisquer problemas de verificação.

  4. Copie os UUIDs dos perfis do Octo. Você precisará deles para o arquivo de fila.

Arquivo de fila

Crie o arquivo upload_queue.json ao lado do script com a seguinte estrutura:

[
  {
    "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"
      }
    ]
  }
]

O campo visibility aceita três valores: PRIVATE, UNLISTED e PUBLIC. Se você gerar a fila a partir de uma planilha ou banco de dados, basta exportá-la no mesmo formato.

Executando o script

  1. Baixe e instale o VS Code.

  2. Baixe e instale o Node.js.

  3. Crie uma pasta, por exemplo octo_youtube_uploader, e abra-a no VS Code.

  4. Crie o arquivo octo_youtube_uploader.js e cole o código do script nele.

  5. Coloque o arquivo upload_queue.json com seus perfis e vídeos ao lado dele.

  6. Verifique os parâmetros na variável config. Os principais são daily_limit_per_channel (quantos vídeos enviar por canal a cada execução), delay_between_uploads (a pausa entre uploads em segundos) e upload_timeout_min (quantos minutos esperar para a conclusão do processamento do vídeo).

  7. Abra o terminal e execute npm i rebrowser-puppeteer axios.

  8. Inicie o Octo Browser.

  9. Execute o script (Ctrl/Cmd + F5) e acompanhe o progresso no console.

Se o VS Code exibir um erro ao instalar as dependências, abra o PowerShell como administrador, execute Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, confirme e repita a instalação.

Lembre-se de que o upload e processamento de um vídeo leva minutos, não segundos. Uma execução com vinte vídeos pode levar horas, e isso é normal. Não encurte as pausas para acelerar o processo: intervalos curtos e consistentes entre as ações são exatamente o que revela padrões de automação.

Código do 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.`);
})();

Lista de verificação antes de rodar toda a rede

  1. Os canais foram devidamente preparados. Um canal novo que recebe vinte vídeos no primeiro dia parece suspeito, independentemente de como é populado. Comece com dois ou três uploads por dia e cresça o canal gradualmente.

  2. Os proxies são diferentes e não pertencem a datacenters. Um único endereço IP para vinte canais anula o isolamento do perfil.

  3. Os metadados não são padronizados. Descrições idênticas com conjuntos idênticos de links em toda a rede são um dos sinais mais óbvios de que os canais estão conectados. Gere variações.

  4. Os seletores foram testados. O Studio muda com o tempo, por isso, antes de uma grande execução, execute o script em um perfil com headless_mode: false e inspecione visualmente onde ele pode falhar.

  5. Um vídeo por vez por perfil. Uploads paralelos de vários vídeos a partir do mesmo perfil são limitados pela largura de banda do canal e aumentam a chance de timeouts. Paralelize perfis, não vídeos dentro de um perfil.

  6. O conteúdo não é duplicado. Enviar o mesmo arquivo para dez canais pode se enquadrar na política de conteúdo reutilizado do YouTube. A localização, edição e narração devem diferir.

Uma abordagem híbrida: uploads pelo navegador, gerenciamento por API

Essa abordagem costumava ser justificada pelo seu custo: o upload custava 1.600 unidades, enquanto tudo o mais custava entre 1 e 50. Após a redução de preço em dezembro, o argumento do custo desapareceu, mas a abordagem em si continua útil, simplesmente por um motivo diferente.

Uploads via API a partir de um projeto não verificado resultam em vídeos privados bloqueados, enquanto o limite de chamadas de videos.insert é de cem por dia por projeto e só pode ser expandido por meio de auditoria. Todas as outras operações não estão sujeitas a nenhuma das duas restrições: são deduzidas do pool geral de 10.000 unidades, onde a edição de metadados custa 50 unidades e a leitura custa uma.

Isso leva a uma divisão de responsabilidades. Os arquivos são enviados por meio de automação do navegador porque isso evita a regra do cliente não verificado e não consome o escasso limite de upload da API. Depois disso, a API assume o controle: edita títulos e descrições via videos.update, define miniaturas via thumbnails.set, organiza os vídeos em playlists e coleta estatísticas. Nenhuma auditoria é necessária para essas operações e não há risco de os vídeos serem bloqueados, pois o upload em si foi realizado pelo navegador.

O navegador faz o que é arriscado pela API; a API faz o que é lento e frágil pelo navegador. Editar descrições em massa em duzentos vídeos via API leva um minuto, enquanto fazer isso via automação no Studio leva meio dia.

Conclusão

Os uploads do YouTube em massa enfrentam mais barreiras do que apenas os limites de cotas. Desde o final de 2025, a cota da Data API praticamente deixou de ser um problema: cem uploads por dia por projeto cobrem a maioria dos casos de uso, e o limite pode ser expandido por meio de uma auditoria. O problema do bloqueio como privado para vídeos enviados de projetos não verificados é resolvido por essa mesma auditoria ou fazendo o upload dos vídeos no navegador. O limite diário de upload do canal simplesmente não pode ser contornado, de modo que a carga de trabalho precisa ser distribuída por vários canais.

O script apresentado neste artigo lida com o último caso: ele transforma uma rede de canais em um único fluxo gerenciável, onde a fila reside em um arquivo JSON e cada canal opera a partir de seu próprio perfil isolado com seu próprio proxy. Ele pode ser expandido de várias formas óbvias: atribuir miniaturas, adicionar legendas, programar a publicação em vez de publicar imediatamente ou adicionar um estado de fila persistente para que uma execução continue de onde parou no dia anterior.

A resiliência de um sistema desse tipo não é determinada pelo script, que é a sua parte mais simples. O sistema funciona desde que os canais pareçam independentes: impressões digitais diferentes, endereços IP diferentes, conteúdos diferentes e um ritmo de publicação plausível.

Mantenha-se atualizado com as últimas notícias do Octo Browser

Ao clicar no botão, você concorda com a nossa Política de Privacidade.

Mantenha-se atualizado com as últimas notícias do Octo Browser

Ao clicar no botão, você concorda com a nossa Política de Privacidade.

Mantenha-se atualizado com as últimas notícias do Octo Browser

Ao clicar no botão, você concorda com a nossa Política de Privacidade.

Junte-se ao Octo Browser agora mesmo

Ou entre em contato com a equipe de suporte no chat para tirar dúvidas a qualquer momento.

Junte-se ao Octo Browser agora mesmo

Ou entre em contato com a equipe de suporte no chat para tirar dúvidas a qualquer momento.

Junte-se ao Octo Browser agora mesmo

Ou entre em contato com a equipe de suporte no chat para tirar dúvidas a qualquer momento.

©

2026

Octo Browser

©

2026

Octo Browser

©

2026

Octo Browser