批量上传 YouTube 视频:数据 API 配额和 Studio 自动化


Anton Misiul
Customer Service Specialist
在 YouTube 上批量上传视频很快就会遇到平台限制。当您需要处理数百个视频和多个频道时,通过 YouTube Studio 进行手动操作会变得效率低下,而转向 YouTube Data API 也会引入其自身的限制,从上传配额到视频可见性限制等。
在本文中,我们将探讨批量上传视频的主要方法、YouTube 的限制以及构建此类系统的方法。我们还将单独重新计算当前的 Data API 配额:在 2025 年底,Google 更改了视频上传的费用,并在 2026 年 6 月开始转向更细粒度的配额模型,因此许多较旧的指南已经过时。最后,我们将编写一个实用的 Node.js 脚本,用于通过隔离的 Octo Browser 配置档案将视频上传到多个频道。
内容
保持匿名,充分利用多账户功能,借助市面上最优质的反检测浏览器实现您的目标。
您想以折扣价格尝试 Octo 浏览器吗?
使用促销代码 OCTOBLOG 获得任何订阅 30% 的折扣。此优惠仅适用于新用户。
两项任务,两种方法
在选择自动化方法之前,请先确定您实际想要实现的目标:
批量上传视频到单个频道。 为包含教学讲座、网络研讨会存档或播客剪辑的频道上传两百个视频。在这种情况下,瓶颈在于频道自身的每日上限,正如我们在下文中所见,这一限制是无法绕过的。
运营多个频道。 针对不同国家/地区提供相同内容的多语言本地化版本、针对不同产品服务的频道,或是由代理机构管理的客户频道。在这里,核心问题是如何让这 20 个 Google 账号保持足够的隔离度,以防 YouTube 将它们关联并一次性封禁所有账号。
第一项任务可以通过规划和定时发布来解决。第二项任务则需要配置文件隔离和自动化。让我们来看看这两种方法。
YouTube 内置的批量上传功能
在编写脚本之前,请先确认 YouTube 的内置功能是否已经能够满足您的需求。YouTube 工作室支持一次性接收多个文件:在上传对话框中,您可以选择一批视频,它们都会被添加为草稿,然后您可以通过勾选多个复选框来批量编辑它们的元数据。您还可以在此处配置定时发布,从而将 20 个草稿排程到下个月发布。
对于单个频道的常规工作,这通常就足够了。当您拥有超过 50 个视频、超过 3 个频道,或者元数据是通过程序生成并存储在 CSV 文件或数据库中时,自动化才开始展现其价值。
YouTube Data API v3 及其两道天花板
上传视频的官方方法是使用 videos.insert 方法。它支持断点续传,支持最大 256 GB 的文件,且仅通过 OAuth 2.0 运行。服务账号不适用于代表频道进行上传,这导致在无需交互式授权的情况下构建服务器端上传器的首次尝试就以失败告终:您必须为每个频道至少手动获取一次刷新令牌。
然而,授权并不是最核心的问题。
配额:关于该主题的几乎所有指南都已过时
如果您以前研究过批量上传,您肯定看到过以下计算:“10,000 个单位除以每次上传消耗的 1,600 个单位,等于每天只能上传 6 个视频。” 这一计算已经不再有效,这是在 2026 年最需要了解的重要一点。
Google 两次修改了配额模型。在 2025 年 12 月 4 日,视频上传的成本从大约 1,600 个单位降至约 100 个单位。随后在 2026 年 6 月 1 日,API 转向了细分配额系统:videos.insert 和 search.list 调用将从各自独立的额度池中扣除,而不是从单个共享额度池中扣除。
当前的默认项目配额如下所示:
额度池 | 默认限制 |
|---|---|
| 每天 100 次调用 |
| 每天 100 次调用 |
所有其他方法 | 每天共计 10,000 个单位 |
这些额度池是相互独立的:一旦您用完了 100 次上传机会,videos.insert 就会返回 403 错误,即使通用额度池中还剩 9,000 个单位未被使用。
通用额度池内各种方法的成本并未改变:
读取操作消耗 1 个单位;
诸如
videos.update和thumbnails.set等写入操作消耗 50 个单位;captions.insert的费用明显更贵,需要 400 个单位。
请小心阅读 Google 的文档:在撰写本文时,其内容存在自相矛盾。其“配额计算器”页面于 2025 年 8 月进行了更新,但仍显示 videos.insert 消耗旧的 1,600 个单位。英文版概述以及 修订历史 中 2026 年 6 月 1 日的记录才是最新的信息来源。您应该始终在 Google Cloud Console 中查看项目的实际限制:旧项目和新项目的配额值可能会有所不同。
结论: 每个项目每天 100 次的上传限制已不再是需要寻找替代方案的瓶颈。真正的限制在别处。
第一道天花板:审核与强制锁定私密
让我们谈谈最常导致自定义上传器失效的问题,这与配额变化无关。在 2020 年 7 月 28 日之后创建的未通过验证的 API 项目中,所有使用 videos.insert 上传的视频都会被强制转换为私密状态。频道所有者会收到一封电子邮件,告知视频已被锁定为私密,因为它是通过未经验证的客户端上传的。
对此无法进行申诉。唯一的解决办法是通过官方或经过审核的客户端,或者通过 YouTube 网站重新上传视频。因此,在通过审核之前,上传器虽然形式上可以运行并返回 200 OK 并给您一个视频 ID,但除了您之外,没有人能够看到它的工作成果。
在 2020 年 7 月之前创建的项目不受此规则限制。如果您有这样的项目,那么您很幸运。其他人要么必须通过审核,要么只能放弃公共 API 上传。
第二道天花板:频道的每日上传限制
这是最让人恼火的限制,因为完全没有办法规避。YouTube 限制了单个频道在 24 小时内可以上传的视频数量,且未公布具体数据。我们知道,该限制取决于频道的历史记录、地区以及是否存在违规处罚(strikes),并且它同时适用于网页版、移动应用和 API。根据社区的观察,新频道每天大约可以上传 10-20 个视频,运营已久的频道每天最多可上传 100 个,但这只是观察结果,并非官方文档记录的数值。
在 API 中,此限制会显示为独立的 uploadLimitExceeded 错误,错误代码为 400,区分这一点与项目配额用尽非常重要:前者适用于频道,而后者适用于项目。
如果达到了频道限制,您必须等待 24 小时。更改上传方式或联系客户支持都无济于事。提高吞吐量唯一有效的方法不是向一个频道上传更多内容,而是将工作负载分散到多个频道中。
架构结论
在新的配额模型下,核心问题不再是 API 请求的数量。对于大多数任务来说,每个项目每天 100 次上传已经足够。但是,在通过审核之前,通过 API 上传公共内容是毫无用处的,而且无论采用何种上传方式,单个频道都无法进行垂直扩容。这只剩下了水平扩展一条路:增加频道数量,减少每个频道的上传量。
当您管理 20 个频道时,您需要一个系统化的多账号管理策略。Google 比大多数平台更具攻击性地关联账号:通过设备指纹、共享的 cookie 会话、匹配的 Client Hints 以及 IP 地址。在同一个 IP 下、由同一个 Chrome 实例运行的 20 个频道将被归为一个集群,随后可能会被整体封禁。
因此,该解决方案的架构如下所示:
针对多账号管理的反检测浏览器 Octo Browser:每个频道一个隔离的配置文件,每个配置文件拥有独立的指纹和代理。配置文件包含活跃的 Google 会话,因此这里不适合使用临时性配置文件,您需要持久性的配置文件。您可以方便地将它们整理到文件夹中,以便将属于同一个项目的频道归类在一起。
Rebrowser-puppeteer:一个没有典型自动化特征泄漏的 Puppeteer 分支。
Octo Local API:启动配置文件并通过 CDP 连接到它们。该方法的完整说明可以在文档和 API 参考中找到。
JSON 任务队列:包含配置文件列表、其关联的视频以及元数据的文件。这可以保持脚本的通用性,同时所有具体细节都从输入数据中获取。
如何使用 YouTube 工作室选择器
YouTube 工作室界面是使用 Polymer Web 组件构建的,这里的选择器策略与我们用于抓取 Instagram 的策略有所不同。
坏消息是:YouTube 工作室中的 CSS 类是生成的且生命周期很短。好消息是:关键元素具有稳定的 ID 和属性,这些属性与界面语言无关。移至下一步的按钮始终是 #next-button。可见性单选按钮为 tp-yt-paper-radio-button[name="PRIVATE"]、[name="UNLISTED"]、[name="PUBLIC"]。儿童内容开关是 [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]。当账号语言环境更改时,这些都不会发生改变。
实用的经验总结:请依赖属性和 ID,而不是按钮文本。如果一个脚本寻找包含“下一步”字样的按钮,那么一旦在具有不同界面语言的配置文件上运行,它就会崩溃。而使用 #next-button 的脚本在任何语言下都可以工作,并且无需通过 ?hl=zh 强制设置语言环境。
第二个技巧涉及文件上传本身。点击拖拽区域是没有意义的:它会打开操作系统的原生文件选择对话框,而这无法通过浏览器关闭。相反,我们可以通过 uploadFile() 将文件直接传递给隐藏的 input[type="file"] 。在这种情况下,操作系统的对话框根本不会出现。
用于向多个频道上传视频的现成脚本
该脚本会依次遍历 Octo 配置文件,在每个配置文件中打开 YouTube 工作室,并上传分配给该配置文件的视频:它会填写标题、描述和标签,将内容标记为非儿童内容,设置可见性,并等待处理完成。在每次上传之间,它会进行随机停顿,计算每个频道的上传量,并在达到配置的每日限制时停止。
配置文件准备
为每个频道创建一个 Octo 配置文件,并为每个配置文件分配其专属代理。推荐使用住宅或移动代理。在使用之前,您应当检查其可靠性。
手动打开每个配置文件并登录对应的 Google 账号。进入 YouTube 工作室,接受所有初始提示,并在必要时确认电话号码。脚本不应该遇到首次使用的引导流程。
从每个配置文件中手动上传一个视频。这可以确认频道已准备好接收上传,同时也可以解决任何验证问题。
从 Octo 复制配置文件的 UUID。您在队列文件中会用到它们。
队列文件
在脚本同级目录下创建具有以下结构的 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。如果您是从电子表格或数据库生成队列,只需将其导出为相同的格式即可。
运行脚本
下载并安装 VS Code。
下载并安装 Node.js。
创建一个文件夹,例如
octo_youtube_uploader,并在 VS Code 中打开它。创建
octo_youtube_uploader.js并将脚本代码粘贴到其中。将包含您的配置文件和视频信息的
upload_queue.json放在脚本同级目录下。检查
config变量中的参数。主要参数有daily_limit_per_channel(每次运行往一个频道上传多少个视频)、delay_between_uploads(两次上传之间的停顿秒数)和upload_timeout_min(等待视频完成处理的分钟数)。打开终端并运行
npm i rebrowser-puppeteer axios。启动 Octo Browser。
运行脚本(Ctrl/Cmd + F5)并在控制台中监控进度。
如果 VS Code 在安装依赖项时显示错误,请以管理员身份打开 PowerShell,运行
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned,确认后重新尝试安装。
请记住,上传和处理一个视频需要几分钟,而不是几秒钟。包含 20 个视频的运行过程可能会持续数小时,这很正常。不要为了加速而缩短停顿时间:操作之间简短且过于规律的间隔恰恰是暴露自动化模式的特征。
脚本代码
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.`); })();
运行整个网络前的检查清单
频道已经过妥善准备。 一个在第一天就上传了 20 个视频的新频道,无论内容是如何填充的,看起来都很可疑。建议从每天两到三次上传开始,逐步增加频道活跃度。
代理是不同的,且不采用数据中心 IP。 20 个频道共用一个 IP 地址会导致配置文件的隔离失效。
元数据未模板化。 整个网络中包含完全相同链接组合的相同描述,是频道之间存在关联最明显的迹象之一。请生成多样化的版本。
选择器已通过测试。 YouTube 工作室会随着时间推移发生变化,因此在进行大规模运行之前,请在
headless_mode: false的单个配置文件上执行脚本,并视觉检查可能发生失败的步骤。每个配置文件每次仅上传一个视频。 同一个配置文件并行上传多个视频会受到频道带宽的限制,并增加超时概率。应当让配置文件并行,而不是在单个配置文件中并行处理视频。
内容不重复。 将同一文件上传到 10 个频道可能会触发 YouTube 的“重复内容”政策。本地化、剪辑和配音应该有所不同。
混合方法:浏览器上传,API 管理
这种方法过去因其成本而具有合理性:上传需要消耗 1,600 个单位,而其他所有操作消耗在 1 到 50 个单位之间。在去年 12 月降价之后,成本方面的理由已不复存在,但该方法本身仍然有用,只是出于不同的原因。
通过未验证项目的 API 进行上传会导致视频被锁定为私密,而 videos.insert 配额上限为每个项目每天 100 次调用,且只能通过审核来扩大。所有其他操作均不受此限制:它们将从通用 10,000 个单位额度池中扣除,其中编辑元数据消耗 50 个单位,读取消耗 1 个单位。
这催生了职责分工模式。文件通过浏览器自动化进行上传,因为这可以避开未验证客户端规则,且不消耗稀缺的上传额度。在此之后,API 接管后续工作:它通过 videos.update 编辑标题和描述,通过 thumbnails.set 设置缩略图,将视频整理到播放列表中,并收集统计数据。这些操作无需审核,且不存在因上传方式本身由 API 执行而导致视频被锁定的风险。
浏览器负责处理 API 认为有风险的部分;API 负责处理浏览器认为缓慢且脆弱的部分。通过 API 批量编辑 200 个视频的描述仅需一分钟,而通过 YouTube 工作室自动化执行则需要半天。
结论
批量 YouTube 上传面临的不仅仅是配额限制。自 2025 年底以来,Data API 配额在很大程度上已不再是问题:每个项目每天 100 次上传覆盖了大多数使用场景,且限制可以通过审核来放宽。未经验证的项目上传视频导致强制私密的问题,可以通过上述审核或在浏览器中上传视频来解决。频道的每日上传限制是无法绕过的,因此必须将工作负载分配到多个频道。
本文中的脚本正是为了解决最后一种情况:它将一个频道网络转变为单个可管理的管线,其中队列存在于 JSON 中,每个频道在其自带代理的独立配置文件下运行。它可以进行多方面扩展:指定缩略图、添加字幕、定时发布而不是立即发布,或者添加持久化队列状态以便在第二天继续从未完成的位置运行。
此类系统的弹性并非由脚本决定,脚本只是其中最简单的部分。只要各频道看起来彼此独立——拥有不同的指纹、不同的 IP 地址、不同的内容以及合理的发布节奏——系统就能保持正常运转。
保持匿名,充分利用多账户功能,借助市面上最优质的反检测浏览器实现您的目标。
您想以折扣价格尝试 Octo 浏览器吗?
使用促销代码 OCTOBLOG 获得任何订阅 30% 的折扣。此优惠仅适用于新用户。
两项任务,两种方法
在选择自动化方法之前,请先确定您实际想要实现的目标:
批量上传视频到单个频道。 为包含教学讲座、网络研讨会存档或播客剪辑的频道上传两百个视频。在这种情况下,瓶颈在于频道自身的每日上限,正如我们在下文中所见,这一限制是无法绕过的。
运营多个频道。 针对不同国家/地区提供相同内容的多语言本地化版本、针对不同产品服务的频道,或是由代理机构管理的客户频道。在这里,核心问题是如何让这 20 个 Google 账号保持足够的隔离度,以防 YouTube 将它们关联并一次性封禁所有账号。
第一项任务可以通过规划和定时发布来解决。第二项任务则需要配置文件隔离和自动化。让我们来看看这两种方法。
YouTube 内置的批量上传功能
在编写脚本之前,请先确认 YouTube 的内置功能是否已经能够满足您的需求。YouTube 工作室支持一次性接收多个文件:在上传对话框中,您可以选择一批视频,它们都会被添加为草稿,然后您可以通过勾选多个复选框来批量编辑它们的元数据。您还可以在此处配置定时发布,从而将 20 个草稿排程到下个月发布。
对于单个频道的常规工作,这通常就足够了。当您拥有超过 50 个视频、超过 3 个频道,或者元数据是通过程序生成并存储在 CSV 文件或数据库中时,自动化才开始展现其价值。
YouTube Data API v3 及其两道天花板
上传视频的官方方法是使用 videos.insert 方法。它支持断点续传,支持最大 256 GB 的文件,且仅通过 OAuth 2.0 运行。服务账号不适用于代表频道进行上传,这导致在无需交互式授权的情况下构建服务器端上传器的首次尝试就以失败告终:您必须为每个频道至少手动获取一次刷新令牌。
然而,授权并不是最核心的问题。
配额:关于该主题的几乎所有指南都已过时
如果您以前研究过批量上传,您肯定看到过以下计算:“10,000 个单位除以每次上传消耗的 1,600 个单位,等于每天只能上传 6 个视频。” 这一计算已经不再有效,这是在 2026 年最需要了解的重要一点。
Google 两次修改了配额模型。在 2025 年 12 月 4 日,视频上传的成本从大约 1,600 个单位降至约 100 个单位。随后在 2026 年 6 月 1 日,API 转向了细分配额系统:videos.insert 和 search.list 调用将从各自独立的额度池中扣除,而不是从单个共享额度池中扣除。
当前的默认项目配额如下所示:
额度池 | 默认限制 |
|---|---|
| 每天 100 次调用 |
| 每天 100 次调用 |
所有其他方法 | 每天共计 10,000 个单位 |
这些额度池是相互独立的:一旦您用完了 100 次上传机会,videos.insert 就会返回 403 错误,即使通用额度池中还剩 9,000 个单位未被使用。
通用额度池内各种方法的成本并未改变:
读取操作消耗 1 个单位;
诸如
videos.update和thumbnails.set等写入操作消耗 50 个单位;captions.insert的费用明显更贵,需要 400 个单位。
请小心阅读 Google 的文档:在撰写本文时,其内容存在自相矛盾。其“配额计算器”页面于 2025 年 8 月进行了更新,但仍显示 videos.insert 消耗旧的 1,600 个单位。英文版概述以及 修订历史 中 2026 年 6 月 1 日的记录才是最新的信息来源。您应该始终在 Google Cloud Console 中查看项目的实际限制:旧项目和新项目的配额值可能会有所不同。
结论: 每个项目每天 100 次的上传限制已不再是需要寻找替代方案的瓶颈。真正的限制在别处。
第一道天花板:审核与强制锁定私密
让我们谈谈最常导致自定义上传器失效的问题,这与配额变化无关。在 2020 年 7 月 28 日之后创建的未通过验证的 API 项目中,所有使用 videos.insert 上传的视频都会被强制转换为私密状态。频道所有者会收到一封电子邮件,告知视频已被锁定为私密,因为它是通过未经验证的客户端上传的。
对此无法进行申诉。唯一的解决办法是通过官方或经过审核的客户端,或者通过 YouTube 网站重新上传视频。因此,在通过审核之前,上传器虽然形式上可以运行并返回 200 OK 并给您一个视频 ID,但除了您之外,没有人能够看到它的工作成果。
在 2020 年 7 月之前创建的项目不受此规则限制。如果您有这样的项目,那么您很幸运。其他人要么必须通过审核,要么只能放弃公共 API 上传。
第二道天花板:频道的每日上传限制
这是最让人恼火的限制,因为完全没有办法规避。YouTube 限制了单个频道在 24 小时内可以上传的视频数量,且未公布具体数据。我们知道,该限制取决于频道的历史记录、地区以及是否存在违规处罚(strikes),并且它同时适用于网页版、移动应用和 API。根据社区的观察,新频道每天大约可以上传 10-20 个视频,运营已久的频道每天最多可上传 100 个,但这只是观察结果,并非官方文档记录的数值。
在 API 中,此限制会显示为独立的 uploadLimitExceeded 错误,错误代码为 400,区分这一点与项目配额用尽非常重要:前者适用于频道,而后者适用于项目。
如果达到了频道限制,您必须等待 24 小时。更改上传方式或联系客户支持都无济于事。提高吞吐量唯一有效的方法不是向一个频道上传更多内容,而是将工作负载分散到多个频道中。
架构结论
在新的配额模型下,核心问题不再是 API 请求的数量。对于大多数任务来说,每个项目每天 100 次上传已经足够。但是,在通过审核之前,通过 API 上传公共内容是毫无用处的,而且无论采用何种上传方式,单个频道都无法进行垂直扩容。这只剩下了水平扩展一条路:增加频道数量,减少每个频道的上传量。
当您管理 20 个频道时,您需要一个系统化的多账号管理策略。Google 比大多数平台更具攻击性地关联账号:通过设备指纹、共享的 cookie 会话、匹配的 Client Hints 以及 IP 地址。在同一个 IP 下、由同一个 Chrome 实例运行的 20 个频道将被归为一个集群,随后可能会被整体封禁。
因此,该解决方案的架构如下所示:
针对多账号管理的反检测浏览器 Octo Browser:每个频道一个隔离的配置文件,每个配置文件拥有独立的指纹和代理。配置文件包含活跃的 Google 会话,因此这里不适合使用临时性配置文件,您需要持久性的配置文件。您可以方便地将它们整理到文件夹中,以便将属于同一个项目的频道归类在一起。
Rebrowser-puppeteer:一个没有典型自动化特征泄漏的 Puppeteer 分支。
Octo Local API:启动配置文件并通过 CDP 连接到它们。该方法的完整说明可以在文档和 API 参考中找到。
JSON 任务队列:包含配置文件列表、其关联的视频以及元数据的文件。这可以保持脚本的通用性,同时所有具体细节都从输入数据中获取。
如何使用 YouTube 工作室选择器
YouTube 工作室界面是使用 Polymer Web 组件构建的,这里的选择器策略与我们用于抓取 Instagram 的策略有所不同。
坏消息是:YouTube 工作室中的 CSS 类是生成的且生命周期很短。好消息是:关键元素具有稳定的 ID 和属性,这些属性与界面语言无关。移至下一步的按钮始终是 #next-button。可见性单选按钮为 tp-yt-paper-radio-button[name="PRIVATE"]、[name="UNLISTED"]、[name="PUBLIC"]。儿童内容开关是 [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]。当账号语言环境更改时,这些都不会发生改变。
实用的经验总结:请依赖属性和 ID,而不是按钮文本。如果一个脚本寻找包含“下一步”字样的按钮,那么一旦在具有不同界面语言的配置文件上运行,它就会崩溃。而使用 #next-button 的脚本在任何语言下都可以工作,并且无需通过 ?hl=zh 强制设置语言环境。
第二个技巧涉及文件上传本身。点击拖拽区域是没有意义的:它会打开操作系统的原生文件选择对话框,而这无法通过浏览器关闭。相反,我们可以通过 uploadFile() 将文件直接传递给隐藏的 input[type="file"] 。在这种情况下,操作系统的对话框根本不会出现。
用于向多个频道上传视频的现成脚本
该脚本会依次遍历 Octo 配置文件,在每个配置文件中打开 YouTube 工作室,并上传分配给该配置文件的视频:它会填写标题、描述和标签,将内容标记为非儿童内容,设置可见性,并等待处理完成。在每次上传之间,它会进行随机停顿,计算每个频道的上传量,并在达到配置的每日限制时停止。
配置文件准备
为每个频道创建一个 Octo 配置文件,并为每个配置文件分配其专属代理。推荐使用住宅或移动代理。在使用之前,您应当检查其可靠性。
手动打开每个配置文件并登录对应的 Google 账号。进入 YouTube 工作室,接受所有初始提示,并在必要时确认电话号码。脚本不应该遇到首次使用的引导流程。
从每个配置文件中手动上传一个视频。这可以确认频道已准备好接收上传,同时也可以解决任何验证问题。
从 Octo 复制配置文件的 UUID。您在队列文件中会用到它们。
队列文件
在脚本同级目录下创建具有以下结构的 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。如果您是从电子表格或数据库生成队列,只需将其导出为相同的格式即可。
运行脚本
下载并安装 VS Code。
下载并安装 Node.js。
创建一个文件夹,例如
octo_youtube_uploader,并在 VS Code 中打开它。创建
octo_youtube_uploader.js并将脚本代码粘贴到其中。将包含您的配置文件和视频信息的
upload_queue.json放在脚本同级目录下。检查
config变量中的参数。主要参数有daily_limit_per_channel(每次运行往一个频道上传多少个视频)、delay_between_uploads(两次上传之间的停顿秒数)和upload_timeout_min(等待视频完成处理的分钟数)。打开终端并运行
npm i rebrowser-puppeteer axios。启动 Octo Browser。
运行脚本(Ctrl/Cmd + F5)并在控制台中监控进度。
如果 VS Code 在安装依赖项时显示错误,请以管理员身份打开 PowerShell,运行
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned,确认后重新尝试安装。
请记住,上传和处理一个视频需要几分钟,而不是几秒钟。包含 20 个视频的运行过程可能会持续数小时,这很正常。不要为了加速而缩短停顿时间:操作之间简短且过于规律的间隔恰恰是暴露自动化模式的特征。
脚本代码
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.`); })();
运行整个网络前的检查清单
频道已经过妥善准备。 一个在第一天就上传了 20 个视频的新频道,无论内容是如何填充的,看起来都很可疑。建议从每天两到三次上传开始,逐步增加频道活跃度。
代理是不同的,且不采用数据中心 IP。 20 个频道共用一个 IP 地址会导致配置文件的隔离失效。
元数据未模板化。 整个网络中包含完全相同链接组合的相同描述,是频道之间存在关联最明显的迹象之一。请生成多样化的版本。
选择器已通过测试。 YouTube 工作室会随着时间推移发生变化,因此在进行大规模运行之前,请在
headless_mode: false的单个配置文件上执行脚本,并视觉检查可能发生失败的步骤。每个配置文件每次仅上传一个视频。 同一个配置文件并行上传多个视频会受到频道带宽的限制,并增加超时概率。应当让配置文件并行,而不是在单个配置文件中并行处理视频。
内容不重复。 将同一文件上传到 10 个频道可能会触发 YouTube 的“重复内容”政策。本地化、剪辑和配音应该有所不同。
混合方法:浏览器上传,API 管理
这种方法过去因其成本而具有合理性:上传需要消耗 1,600 个单位,而其他所有操作消耗在 1 到 50 个单位之间。在去年 12 月降价之后,成本方面的理由已不复存在,但该方法本身仍然有用,只是出于不同的原因。
通过未验证项目的 API 进行上传会导致视频被锁定为私密,而 videos.insert 配额上限为每个项目每天 100 次调用,且只能通过审核来扩大。所有其他操作均不受此限制:它们将从通用 10,000 个单位额度池中扣除,其中编辑元数据消耗 50 个单位,读取消耗 1 个单位。
这催生了职责分工模式。文件通过浏览器自动化进行上传,因为这可以避开未验证客户端规则,且不消耗稀缺的上传额度。在此之后,API 接管后续工作:它通过 videos.update 编辑标题和描述,通过 thumbnails.set 设置缩略图,将视频整理到播放列表中,并收集统计数据。这些操作无需审核,且不存在因上传方式本身由 API 执行而导致视频被锁定的风险。
浏览器负责处理 API 认为有风险的部分;API 负责处理浏览器认为缓慢且脆弱的部分。通过 API 批量编辑 200 个视频的描述仅需一分钟,而通过 YouTube 工作室自动化执行则需要半天。
结论
批量 YouTube 上传面临的不仅仅是配额限制。自 2025 年底以来,Data API 配额在很大程度上已不再是问题:每个项目每天 100 次上传覆盖了大多数使用场景,且限制可以通过审核来放宽。未经验证的项目上传视频导致强制私密的问题,可以通过上述审核或在浏览器中上传视频来解决。频道的每日上传限制是无法绕过的,因此必须将工作负载分配到多个频道。
本文中的脚本正是为了解决最后一种情况:它将一个频道网络转变为单个可管理的管线,其中队列存在于 JSON 中,每个频道在其自带代理的独立配置文件下运行。它可以进行多方面扩展:指定缩略图、添加字幕、定时发布而不是立即发布,或者添加持久化队列状态以便在第二天继续从未完成的位置运行。
此类系统的弹性并非由脚本决定,脚本只是其中最简单的部分。只要各频道看起来彼此独立——拥有不同的指纹、不同的 IP 地址、不同的内容以及合理的发布节奏——系统就能保持正常运转。
随时获取最新的Octo Browser新闻
通过点击按钮,您同意我们的 隐私政策。
随时获取最新的Octo Browser新闻
通过点击按钮,您同意我们的 隐私政策。
随时获取最新的Octo Browser新闻
通过点击按钮,您同意我们的 隐私政策。
