Массовая загрузка видео на YouTube: квоты Data API и автоматизация Studio

Массовая загрузка видео на YouTube: квоты Data API и автоматизация Studio
Anton Misiul
Anton Misiul

Customer Service Specialist

Массовая загрузка видео на YouTube быстро упирается в ограничения платформы. Когда речь идет о сотнях роликов и нескольких каналах, ручная работа через YouTube Studio становится неэффективной, а переход на YouTube Data API добавляет свои ограничения — от квот на загрузку до ограничения доступа к видео.

В этой статье разберем основные способы массовой загрузки видео, ограничения YouTube и варианты построения такой системы. Отдельно пересчитаем актуальные квоты Data API: в конце 2025 года Google изменил стоимость загрузки видео, а в июне 2026 года начал переход на более детальную модель квот, поэтому многие старые инструкции уже не соответствуют текущим правилам. В конце соберем рабочий скрипт на Node.js для загрузки роликов в несколько каналов через изолированные профили Octo Browser.

Содержание

Сохраняйте анонимность, используйте преимущества мультиаккаунтинга и добивайтесь своих целей с самым качественным решением на рынке антидетект-браузеров.

Хотите попробовать Octo Browser со скидкой?
По промокоду OCTOBLOG получите 30% скидку на любую подписку. Предложение действительно только для новых пользователей.

Две задачи — два подхода

Прежде чем выбирать способ автоматизации, нужно определиться с вашей задачей:

  • Массовая загрузка видео на один канал. Двести видео на канал с обучающими лекциями, архив вебинаров, нарезка подкаста. Здесь узкое место — суточный лимит самого канала, и он, как мы увидим дальше, не обходится ничем.

  • Работа с несколькими каналами. Локализованные версии одного контента под разные страны, каналы под разные офферы, клиентские каналы агентства. Здесь главный вопрос — как держать двадцать аккаунтов Google так, чтобы YouTube не связал их между собой и не заблокировал массово.

Первая задача решается планированием и отложенной публикацией. Вторая — изоляцией профилей и автоматизацией. Разберем оба подхода.

Штатные возможности YouTube для массовой загрузки

Прежде чем писать скрипты, проверьте, не хватает ли вам штатных возможностей. YouTube Studio принимает несколько файлов за один раз: в диалоге загрузки можно выделить сразу пачку роликов, все они уйдут в черновики, и дальше можно массово править метаданные через выделение чекбоксами. Там же настраивается отложенная публикация, так что двадцать черновиков можно расписать на месяц вперед.

Для регулярной работы с одним каналом этого часто достаточно. Автоматизация начинает окупаться примерно тогда, когда роликов больше пятидесяти, каналов больше трех либо метаданные генерируются программно и лежат в CSV или в базе.

YouTube Data API v3 и два его потолка

Официальный путь для загрузки — метод videos.insert. Он поддерживает resumable upload, принимает файлы до 256 ГБ и работает только через OAuth 2.0. Сервисные аккаунты для загрузки от имени канала не подходят, что ломает первую же попытку сделать серверный загрузчик без интерактивной авторизации: refresh-токен придется получить руками хотя бы один раз на канал.

Но главная проблема не в авторизации.

Квота: почти все гайды по этой теме устарели

Если вы искали материалы по массовой загрузке раньше, вы почти наверняка встречали расчет «10 000 юнитов делим на 1 600 за загрузку — получаем шесть роликов в сутки». Этот расчет больше не действует, и это главное, что нужно знать в 2026 году.

Google дважды менял модель квот. Четвертого декабря 2025 года стоимость загрузки видео снизилась с ≈1 600 юнитов до ≈100. А 1 июня 2026 года API перешел на гранулярную систему квот: вызовы videos.insert и search.list списываются каждый со своего отдельного бакета, а не из общего пула.

Актуальная дефолтная квота проекта выглядит так:

Бакет

Дефолтный лимит

videos.insert (загрузка видео)

100 вызовов в сутки

search.list (поиск)

100 вызовов в сутки

Все остальные методы

10 000 юнитов в сутки суммарно

Бакеты независимы: исчерпав сотню загрузок, вы получите 403 на videos.insert, даже если в общем пуле осталось 9 000 юнитов нетронутыми.

Стоимость методов внутри общего пула не изменилась: 

  • чтение по 1 юниту; 

  • записи вроде videos.update и thumbnails.set по 50; 

  • а captions.insert заметно дороже — 400.

Осторожно с документацией Google: на момент написания статьи она противоречит сама себе. Страница «Калькулятор квот» обновлялась в августе 2025-го и до сих пор показывает для videos.insert старые 1 600 юнитов, а русский перевод обзорной страницы отстает от английского оригинала. Актуальны английская версия обзора и запись от 1 июня 2026 года в Revision History. Реальные лимиты своего проекта всегда стоит смотреть в Google Cloud Console: на старых и новых проектах значения квот могут различаться .

Вывод: сотня загрузок в сутки на проект — это уже не то узкое место, ради которого требуются обходные схемы. Настоящие ограничения лежат в другой плоскости.

Потолок первый: аудит и locked private

Поговорим о причине, из-за которой самописные загрузчики чаще всего оказываются бесполезны, независимо от изменения квот. Все видео, загруженные методом videos.insert из непроверенного API-проекта, созданного после 28 июля 2020 года, принудительно переводятся в приватный режим. Владелец канала получает письмо о том, что видео заблокировано как приватное, потому что залито через неверифицированный клиент.

Апелляции по такой блокировке нет. Единственный выход — перезалить ролик через официальный или прошедший аудит клиент либо через сайт YouTube. То есть до прохождения аудита загрузчик формально работает, отдает 200 OK и возвращает ID видео, но результат его работы никто, кроме вас, не увидит.

Проекты, созданные до июля 2020-го, под это правило не попадают. Если у вас есть такой проект, вы в выигрышной позиции. Всем остальным придется либо проходить аудит, либо не рассчитывать на публичные загрузки через API.

Потолок второй: суточный лимит канала

Самое неприятное ограничение, потому что оно не обходится вообще ничем. YouTube ограничивает количество видео, которое канал может загрузить за 24 часа, и точных цифр не публикует. Известно, что лимит зависит от истории канала, региона и наличия страйков и что он считается одновременно для веб-версии, мобильных приложений и API. По наблюдениям сообщества, у новых каналов это примерно 10–20 роликов, у давно живущих — до сотни, но это именно наблюдения, а не документированные значения.

В API этот лимит проявляется отдельной ошибкой uploadLimitExceeded с кодом 400, и отличить ее от исчерпания квоты проекта важно: первая относится к каналу, вторая — к проекту.

Если вы упираетесь в лимит канала, придется подождать сутки. Смена способа загрузки или обращение в поддержку не помогут. Единственный рабочий способ увеличить пропускную способность — не грузить больше в один канал, а распределить нагрузку по нескольким.

Выводы для архитектуры

В новой модели квот основной вопрос уже не в количестве API-запросов. Ста загрузок в сутки на проект достаточно для большинства задач. Но загрузка через API до аудита бесполезна для публичного контента, а вертикально масштабировать один канал нельзя ни при каком способе загрузки. Значит, остается только горизонтальное масштабирование: больше каналов, меньше загрузок на каждый.

А когда вы ведете двадцать каналов, нужна выстроенная стратегия мультиаккаунтинга. Google связывает аккаунты агрессивнее большинства платформ: по отпечатку устройства, по общим куки-сессиям, по совпадающим Client Hints, по IP. Двадцать каналов из одного Chrome под одним IP склеятся в один кластер, который будет целиком заблокирован.

Поэтому архитектура решения выглядит так:

  1. Антидетект-браузер Octo Browser — по одному изолированному профилю на канал, у каждого свой отпечаток и свой прокси. Профили с живыми сессиями Google, поэтому одноразовые профили здесь не подходят, нужны постоянные. Раскладывать их удобно по папкам, чтобы каналы одного проекта лежали вместе.

  2. Rebrowser-puppeteer — форк Puppeteer без характерных утечек признаков автоматизации.

  3. Local API Octo — запуск профилей и подключение к ним по CDP. Полное описание методов есть в документации и в API-референсе.

  4. Очередь заданий в JSON — файл со списком профилей, привязанных к ним роликов и метаданных. Так скрипт остается универсальным, а вся конкретика лежит в данных.

Как работать с селекторами Studio

Интерфейс YouTube Studio собран на веб-компонентах Polymer, и подход к селекторам здесь отличается от того, что мы использовали при парсинге Instagram.

Плохая новость: классы в Studio генерируются и живут недолго. Хорошая: у ключевых элементов есть стабильные идентификаторы и атрибуты, никак не связанные с языком интерфейса. Кнопка перехода к следующему шагу — это всегда #next-button. Радиокнопки видимости — tp-yt-paper-radio-button[name="PRIVATE"], [name="UNLISTED"], [name="PUBLIC"]. Переключатель контента для детей — [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]. Ни один из них не меняется при смене локали аккаунта.

Практический вывод: привязывайтесь к атрибутам и идентификаторам, а не к тексту кнопок. Скрипт, который ищет кнопку по слову «Далее», развалится на первом же профиле с английским интерфейсом. Скрипт на #next-button отработает на любом языке, и принудительно фиксировать локаль через ?hl=en не понадобится.

Второй прием касается самой загрузки файла. Кликать по зоне драг-н-дропа бессмысленно: откроется нативный диалог операционной системы, а его из браузера не закрыть. Вместо этого файл передается напрямую в скрытый input[type="file"] через uploadFile(). Диалог ОС при этом не появляется вообще.

Готовый скрипт для загрузки видео на несколько каналов

Скрипт последовательно проходит по профилям Octo, в каждом открывает Studio и заливает привязанные к профилю ролики: подставляет заголовок, описание и теги, отмечает контент как не предназначенный для детей, выставляет видимость и дожидается окончания обработки. Между загрузками делает случайные паузы, считает загрузки по каждому каналу и останавливается, дойдя до заданного суточного лимита.

Подготовка профилей

  1. Создайте в Octo по профилю на каждый канал и назначьте каждому свой прокси. Лучше резидентный или мобильный. Перед работой стоит проверить его надежность.

  2. Откройте каждый профиль вручную и авторизуйтесь в аккаунте Google. Зайдите в Studio, примите все первичные диалоги, при необходимости подтвердите номер телефона. Скрипт не должен натыкаться на онбординг.

  3. Загрузите из профиля один ролик руками. Так вы убедитесь, что канал вообще готов принимать загрузки, и заодно снимете вопросы с верификацией.

  4. Скопируйте UUID профилей из клиента Octo. Они понадобятся для файла очереди.

Файл очереди

Создайте рядом со скриптом upload_queue.json. Структура такая:

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

Поле visibility принимает три значения: PRIVATE, UNLISTED и PUBLIC. Если генерируете очередь из таблицы или базы, достаточно выгружать ее в этот же формат.

Запуск

  1. Скачайте и установите VS Code.

  2. Скачайте и установите Node.js.

  3. Создайте папку, например octo_youtube_uploader, и откройте ее в VS Code.

  4. Создайте файл octo_youtube_uploader.js и вставьте в него код скрипта.

  5. Положите рядом upload_queue.json со своими профилями и роликами.

  6. Проверьте параметры в переменной config. Основные: daily_limit_per_channel — сколько роликов грузить в один канал за прогон, delay_between_uploads — пауза между загрузками в секундах, upload_timeout_min — сколько минут ждать обработку одного ролика.

  7. Откройте терминал и выполните npm i rebrowser-puppeteer axios.

  8. Запустите клиент Octo Browser.

  9. Запустите скрипт (Ctrl/Cmd + F5) и следите за прогрессом в консоли.

Если VS Code выдает ошибку при установке зависимостей, откройте PowerShell от имени администратора, выполните Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, подтвердите и повторите установку.

Учитывайте, что загрузка и обработка ролика занимают минуты, а не секунды. Прогон по двадцати видео — это часы работы, и это нормально. Не уменьшайте паузы, чтобы ускориться: именно ровные короткие интервалы между действиями и выдают автоматизацию.

Код скрипта

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.`);
})();

Чек-лист перед прогоном на всей сетке

  1. Каналы прогреты. Новый канал, который в первые сутки получает двадцать роликов, выглядит подозрительно независимо от того, чем вы его наполняли. Начинайте с двух-трех загрузок в день и наращивайте постепенно.

  2. Прокси разные и не серверные. Один IP на двадцать каналов сводит на нет всю изоляцию профилей.

  3. Метаданные не шаблонные. Одинаковые описания с одинаковым набором ссылок по всей сетке — самый заметный признак связанности каналов. Генерируйте вариации.

  4. Селекторы проверены. Studio обновляется, и перед большим прогоном стоит запустить скрипт на одном профиле с headless_mode: false и посмотреть глазами, где он спотыкается.

  5. Один ролик в один момент времени. Параллельная загрузка нескольких видео из одного профиля упирается в полосу канала и повышает шанс тайм-аута. Параллелить стоит профили, а не ролики внутри профиля.

  6. Контент не дублируется. Заливка одного и того же файла на десяток каналов попадает под политику о повторно используемом контенте. Локализация, монтаж и озвучка должны различаться.

Гибридная схема: браузер грузит, API управляет

Раньше эту схему обосновывали ценой: загрузка стоила 1 600 юнитов, все остальное — от 1 до 50. После декабрьского удешевления аргумент про стоимость отпал, но сама схема осталась рабочей, просто по другой причине.

Загрузка через API из непроверенного проекта уводит видео в locked private, а бакет videos.insert жестко упирается в сотню вызовов в сутки на проект и расширяется только через аудит. Все прочие операции ни того, ни другого ограничения не знают: они списываются из общего пула на 10 000 юнитов, где правка метаданных стоит 50 юнитов, а чтение — один.

Отсюда разделение обязанностей. Файлы заливаются браузерной автоматизацией, потому что так они не попадают под правило о неверифицированном клиенте и не тратят дефицитный бакет загрузок. А дальше в дело вступает API: правит заголовки и описания через videos.update, ставит превью через thumbnails.set, раскладывает ролики по плейлистам, собирает статистику. Аудит для таких операций не нужен, и блокировка видео здесь не грозит, потому что загрузку делал не API.

Браузер делает то, что через API рискованно, API делает то, что через браузер медленно и хрупко. Массовая правка описаний у двухсот роликов через API занимает минуту, а через автоматизацию Studio — полдня.

Заключение

Массовая загрузка на YouTube упирается не только в лимиты. Квота Data API с конца 2025 года почти перестала быть проблемой: сотня загрузок в сутки на проект закрывает большинство задач, а расширяется она через аудит. Блокировка видео из неверифицированного проекта решается либо тем же аудитом, либо загрузкой через браузер. Суточный лимит канала не обходится ничем, нужно распределять нагрузку по нескольким каналам.

Скрипт из нашей статьи закрывает последний случай: он превращает сетку каналов в один управляемый пайплайн, где очередь лежит в JSON, а каждый канал работает из своего изолированного профиля со своим прокси. Расширять его можно в очевидных направлениях: подставлять превью на том же шаге, добавлять субтитры, ставить отложенную публикацию вместо немедленной, дописать хранение состояния очереди, чтобы прогон продолжался с того места, где остановился вчера.

Устойчивость такой системы определяется не скриптом. Скрипт — это самая простая ее часть. Работает она до тех пор, пока каналы выглядят независимыми: разные отпечатки, разные IP, разный контент и правдоподобный темп публикаций.

Сохраняйте анонимность, используйте преимущества мультиаккаунтинга и добивайтесь своих целей с самым качественным решением на рынке антидетект-браузеров.

Хотите попробовать Octo Browser со скидкой?
По промокоду OCTOBLOG получите 30% скидку на любую подписку. Предложение действительно только для новых пользователей.

Две задачи — два подхода

Прежде чем выбирать способ автоматизации, нужно определиться с вашей задачей:

  • Массовая загрузка видео на один канал. Двести видео на канал с обучающими лекциями, архив вебинаров, нарезка подкаста. Здесь узкое место — суточный лимит самого канала, и он, как мы увидим дальше, не обходится ничем.

  • Работа с несколькими каналами. Локализованные версии одного контента под разные страны, каналы под разные офферы, клиентские каналы агентства. Здесь главный вопрос — как держать двадцать аккаунтов Google так, чтобы YouTube не связал их между собой и не заблокировал массово.

Первая задача решается планированием и отложенной публикацией. Вторая — изоляцией профилей и автоматизацией. Разберем оба подхода.

Штатные возможности YouTube для массовой загрузки

Прежде чем писать скрипты, проверьте, не хватает ли вам штатных возможностей. YouTube Studio принимает несколько файлов за один раз: в диалоге загрузки можно выделить сразу пачку роликов, все они уйдут в черновики, и дальше можно массово править метаданные через выделение чекбоксами. Там же настраивается отложенная публикация, так что двадцать черновиков можно расписать на месяц вперед.

Для регулярной работы с одним каналом этого часто достаточно. Автоматизация начинает окупаться примерно тогда, когда роликов больше пятидесяти, каналов больше трех либо метаданные генерируются программно и лежат в CSV или в базе.

YouTube Data API v3 и два его потолка

Официальный путь для загрузки — метод videos.insert. Он поддерживает resumable upload, принимает файлы до 256 ГБ и работает только через OAuth 2.0. Сервисные аккаунты для загрузки от имени канала не подходят, что ломает первую же попытку сделать серверный загрузчик без интерактивной авторизации: refresh-токен придется получить руками хотя бы один раз на канал.

Но главная проблема не в авторизации.

Квота: почти все гайды по этой теме устарели

Если вы искали материалы по массовой загрузке раньше, вы почти наверняка встречали расчет «10 000 юнитов делим на 1 600 за загрузку — получаем шесть роликов в сутки». Этот расчет больше не действует, и это главное, что нужно знать в 2026 году.

Google дважды менял модель квот. Четвертого декабря 2025 года стоимость загрузки видео снизилась с ≈1 600 юнитов до ≈100. А 1 июня 2026 года API перешел на гранулярную систему квот: вызовы videos.insert и search.list списываются каждый со своего отдельного бакета, а не из общего пула.

Актуальная дефолтная квота проекта выглядит так:

Бакет

Дефолтный лимит

videos.insert (загрузка видео)

100 вызовов в сутки

search.list (поиск)

100 вызовов в сутки

Все остальные методы

10 000 юнитов в сутки суммарно

Бакеты независимы: исчерпав сотню загрузок, вы получите 403 на videos.insert, даже если в общем пуле осталось 9 000 юнитов нетронутыми.

Стоимость методов внутри общего пула не изменилась: 

  • чтение по 1 юниту; 

  • записи вроде videos.update и thumbnails.set по 50; 

  • а captions.insert заметно дороже — 400.

Осторожно с документацией Google: на момент написания статьи она противоречит сама себе. Страница «Калькулятор квот» обновлялась в августе 2025-го и до сих пор показывает для videos.insert старые 1 600 юнитов, а русский перевод обзорной страницы отстает от английского оригинала. Актуальны английская версия обзора и запись от 1 июня 2026 года в Revision History. Реальные лимиты своего проекта всегда стоит смотреть в Google Cloud Console: на старых и новых проектах значения квот могут различаться .

Вывод: сотня загрузок в сутки на проект — это уже не то узкое место, ради которого требуются обходные схемы. Настоящие ограничения лежат в другой плоскости.

Потолок первый: аудит и locked private

Поговорим о причине, из-за которой самописные загрузчики чаще всего оказываются бесполезны, независимо от изменения квот. Все видео, загруженные методом videos.insert из непроверенного API-проекта, созданного после 28 июля 2020 года, принудительно переводятся в приватный режим. Владелец канала получает письмо о том, что видео заблокировано как приватное, потому что залито через неверифицированный клиент.

Апелляции по такой блокировке нет. Единственный выход — перезалить ролик через официальный или прошедший аудит клиент либо через сайт YouTube. То есть до прохождения аудита загрузчик формально работает, отдает 200 OK и возвращает ID видео, но результат его работы никто, кроме вас, не увидит.

Проекты, созданные до июля 2020-го, под это правило не попадают. Если у вас есть такой проект, вы в выигрышной позиции. Всем остальным придется либо проходить аудит, либо не рассчитывать на публичные загрузки через API.

Потолок второй: суточный лимит канала

Самое неприятное ограничение, потому что оно не обходится вообще ничем. YouTube ограничивает количество видео, которое канал может загрузить за 24 часа, и точных цифр не публикует. Известно, что лимит зависит от истории канала, региона и наличия страйков и что он считается одновременно для веб-версии, мобильных приложений и API. По наблюдениям сообщества, у новых каналов это примерно 10–20 роликов, у давно живущих — до сотни, но это именно наблюдения, а не документированные значения.

В API этот лимит проявляется отдельной ошибкой uploadLimitExceeded с кодом 400, и отличить ее от исчерпания квоты проекта важно: первая относится к каналу, вторая — к проекту.

Если вы упираетесь в лимит канала, придется подождать сутки. Смена способа загрузки или обращение в поддержку не помогут. Единственный рабочий способ увеличить пропускную способность — не грузить больше в один канал, а распределить нагрузку по нескольким.

Выводы для архитектуры

В новой модели квот основной вопрос уже не в количестве API-запросов. Ста загрузок в сутки на проект достаточно для большинства задач. Но загрузка через API до аудита бесполезна для публичного контента, а вертикально масштабировать один канал нельзя ни при каком способе загрузки. Значит, остается только горизонтальное масштабирование: больше каналов, меньше загрузок на каждый.

А когда вы ведете двадцать каналов, нужна выстроенная стратегия мультиаккаунтинга. Google связывает аккаунты агрессивнее большинства платформ: по отпечатку устройства, по общим куки-сессиям, по совпадающим Client Hints, по IP. Двадцать каналов из одного Chrome под одним IP склеятся в один кластер, который будет целиком заблокирован.

Поэтому архитектура решения выглядит так:

  1. Антидетект-браузер Octo Browser — по одному изолированному профилю на канал, у каждого свой отпечаток и свой прокси. Профили с живыми сессиями Google, поэтому одноразовые профили здесь не подходят, нужны постоянные. Раскладывать их удобно по папкам, чтобы каналы одного проекта лежали вместе.

  2. Rebrowser-puppeteer — форк Puppeteer без характерных утечек признаков автоматизации.

  3. Local API Octo — запуск профилей и подключение к ним по CDP. Полное описание методов есть в документации и в API-референсе.

  4. Очередь заданий в JSON — файл со списком профилей, привязанных к ним роликов и метаданных. Так скрипт остается универсальным, а вся конкретика лежит в данных.

Как работать с селекторами Studio

Интерфейс YouTube Studio собран на веб-компонентах Polymer, и подход к селекторам здесь отличается от того, что мы использовали при парсинге Instagram.

Плохая новость: классы в Studio генерируются и живут недолго. Хорошая: у ключевых элементов есть стабильные идентификаторы и атрибуты, никак не связанные с языком интерфейса. Кнопка перехода к следующему шагу — это всегда #next-button. Радиокнопки видимости — tp-yt-paper-radio-button[name="PRIVATE"], [name="UNLISTED"], [name="PUBLIC"]. Переключатель контента для детей — [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]. Ни один из них не меняется при смене локали аккаунта.

Практический вывод: привязывайтесь к атрибутам и идентификаторам, а не к тексту кнопок. Скрипт, который ищет кнопку по слову «Далее», развалится на первом же профиле с английским интерфейсом. Скрипт на #next-button отработает на любом языке, и принудительно фиксировать локаль через ?hl=en не понадобится.

Второй прием касается самой загрузки файла. Кликать по зоне драг-н-дропа бессмысленно: откроется нативный диалог операционной системы, а его из браузера не закрыть. Вместо этого файл передается напрямую в скрытый input[type="file"] через uploadFile(). Диалог ОС при этом не появляется вообще.

Готовый скрипт для загрузки видео на несколько каналов

Скрипт последовательно проходит по профилям Octo, в каждом открывает Studio и заливает привязанные к профилю ролики: подставляет заголовок, описание и теги, отмечает контент как не предназначенный для детей, выставляет видимость и дожидается окончания обработки. Между загрузками делает случайные паузы, считает загрузки по каждому каналу и останавливается, дойдя до заданного суточного лимита.

Подготовка профилей

  1. Создайте в Octo по профилю на каждый канал и назначьте каждому свой прокси. Лучше резидентный или мобильный. Перед работой стоит проверить его надежность.

  2. Откройте каждый профиль вручную и авторизуйтесь в аккаунте Google. Зайдите в Studio, примите все первичные диалоги, при необходимости подтвердите номер телефона. Скрипт не должен натыкаться на онбординг.

  3. Загрузите из профиля один ролик руками. Так вы убедитесь, что канал вообще готов принимать загрузки, и заодно снимете вопросы с верификацией.

  4. Скопируйте UUID профилей из клиента Octo. Они понадобятся для файла очереди.

Файл очереди

Создайте рядом со скриптом upload_queue.json. Структура такая:

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

Поле visibility принимает три значения: PRIVATE, UNLISTED и PUBLIC. Если генерируете очередь из таблицы или базы, достаточно выгружать ее в этот же формат.

Запуск

  1. Скачайте и установите VS Code.

  2. Скачайте и установите Node.js.

  3. Создайте папку, например octo_youtube_uploader, и откройте ее в VS Code.

  4. Создайте файл octo_youtube_uploader.js и вставьте в него код скрипта.

  5. Положите рядом upload_queue.json со своими профилями и роликами.

  6. Проверьте параметры в переменной config. Основные: daily_limit_per_channel — сколько роликов грузить в один канал за прогон, delay_between_uploads — пауза между загрузками в секундах, upload_timeout_min — сколько минут ждать обработку одного ролика.

  7. Откройте терминал и выполните npm i rebrowser-puppeteer axios.

  8. Запустите клиент Octo Browser.

  9. Запустите скрипт (Ctrl/Cmd + F5) и следите за прогрессом в консоли.

Если VS Code выдает ошибку при установке зависимостей, откройте PowerShell от имени администратора, выполните Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, подтвердите и повторите установку.

Учитывайте, что загрузка и обработка ролика занимают минуты, а не секунды. Прогон по двадцати видео — это часы работы, и это нормально. Не уменьшайте паузы, чтобы ускориться: именно ровные короткие интервалы между действиями и выдают автоматизацию.

Код скрипта

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.`);
})();

Чек-лист перед прогоном на всей сетке

  1. Каналы прогреты. Новый канал, который в первые сутки получает двадцать роликов, выглядит подозрительно независимо от того, чем вы его наполняли. Начинайте с двух-трех загрузок в день и наращивайте постепенно.

  2. Прокси разные и не серверные. Один IP на двадцать каналов сводит на нет всю изоляцию профилей.

  3. Метаданные не шаблонные. Одинаковые описания с одинаковым набором ссылок по всей сетке — самый заметный признак связанности каналов. Генерируйте вариации.

  4. Селекторы проверены. Studio обновляется, и перед большим прогоном стоит запустить скрипт на одном профиле с headless_mode: false и посмотреть глазами, где он спотыкается.

  5. Один ролик в один момент времени. Параллельная загрузка нескольких видео из одного профиля упирается в полосу канала и повышает шанс тайм-аута. Параллелить стоит профили, а не ролики внутри профиля.

  6. Контент не дублируется. Заливка одного и того же файла на десяток каналов попадает под политику о повторно используемом контенте. Локализация, монтаж и озвучка должны различаться.

Гибридная схема: браузер грузит, API управляет

Раньше эту схему обосновывали ценой: загрузка стоила 1 600 юнитов, все остальное — от 1 до 50. После декабрьского удешевления аргумент про стоимость отпал, но сама схема осталась рабочей, просто по другой причине.

Загрузка через API из непроверенного проекта уводит видео в locked private, а бакет videos.insert жестко упирается в сотню вызовов в сутки на проект и расширяется только через аудит. Все прочие операции ни того, ни другого ограничения не знают: они списываются из общего пула на 10 000 юнитов, где правка метаданных стоит 50 юнитов, а чтение — один.

Отсюда разделение обязанностей. Файлы заливаются браузерной автоматизацией, потому что так они не попадают под правило о неверифицированном клиенте и не тратят дефицитный бакет загрузок. А дальше в дело вступает API: правит заголовки и описания через videos.update, ставит превью через thumbnails.set, раскладывает ролики по плейлистам, собирает статистику. Аудит для таких операций не нужен, и блокировка видео здесь не грозит, потому что загрузку делал не API.

Браузер делает то, что через API рискованно, API делает то, что через браузер медленно и хрупко. Массовая правка описаний у двухсот роликов через API занимает минуту, а через автоматизацию Studio — полдня.

Заключение

Массовая загрузка на YouTube упирается не только в лимиты. Квота Data API с конца 2025 года почти перестала быть проблемой: сотня загрузок в сутки на проект закрывает большинство задач, а расширяется она через аудит. Блокировка видео из неверифицированного проекта решается либо тем же аудитом, либо загрузкой через браузер. Суточный лимит канала не обходится ничем, нужно распределять нагрузку по нескольким каналам.

Скрипт из нашей статьи закрывает последний случай: он превращает сетку каналов в один управляемый пайплайн, где очередь лежит в JSON, а каждый канал работает из своего изолированного профиля со своим прокси. Расширять его можно в очевидных направлениях: подставлять превью на том же шаге, добавлять субтитры, ставить отложенную публикацию вместо немедленной, дописать хранение состояния очереди, чтобы прогон продолжался с того места, где остановился вчера.

Устойчивость такой системы определяется не скриптом. Скрипт — это самая простая ее часть. Работает она до тех пор, пока каналы выглядят независимыми: разные отпечатки, разные IP, разный контент и правдоподобный темп публикаций.

Следите за последними новостями Octo Browser

Нажимая кнопку, вы соглашаетесь с нашей политикой конфиденциальности.

Следите за последними новостями Octo Browser

Нажимая кнопку, вы соглашаетесь с нашей политикой конфиденциальности.

Следите за последними новостями Octo Browser

Нажимая кнопку, вы соглашаетесь с нашей политикой конфиденциальности.

Присоединяйтесь к Octo Browser сейчас

Вы можете обращаться за помощью к нашим специалистам службы поддержки в чате в любое время.

Присоединяйтесь к Octo Browser сейчас

Вы можете обращаться за помощью к нашим специалистам службы поддержки в чате в любое время.

Присоединяйтесь к Octo Browser сейчас

Вы можете обращаться за помощью к нашим специалистам службы поддержки в чате в любое время.

©

2026

Octo Browser

©

2026

Octo Browser

©

2026

Octo Browser