Tải lên hàng loạt video YouTube: Hạn ngạch API dữ liệu và tự động hóa Studio


Anton Misiul
Customer Service Specialist
Tải video hàng loạt lên YouTube sẽ nhanh chóng gặp phải các giới hạn của nền tảng. Khi bạn xử lý hàng trăm video và nhiều kênh khác nhau, công việc thủ công qua YouTube Studio trở nên kém hiệu quả, trong khi việc chuyển sang YouTube Data API lại mang đến những hạn chế riêng, từ hạn ngạch tải lên cho đến các hạn chế về chế độ hiển thị video.
Trong bài viết này, chúng ta sẽ xem xét các cách chính để thực hiện tải video hàng loạt, các giới hạn của YouTube và các cách xây dựng một hệ thống như vậy. Chúng ta cũng sẽ tính toán lại riêng biệt các hạn ngạch Data API hiện tại: vào cuối năm 2025, Google đã thay đổi chi phí tải video lên, và vào tháng 6 năm 2026, họ bắt đầu chuyển sang mô hình hạn ngạch chi tiết hơn, vì vậy nhiều hướng dẫn cũ hiện đã lỗi thời. Cuối cùng, chúng ta sẽ cùng nhau tạo một tập lệnh Node.js hoạt động để tải video lên nhiều kênh thông qua các hồ sơ Octo Browser riêng biệt.
Nội dung
Giữ kín danh tính, tận dụng tính năng nhiều tài khoản và đạt được mục tiêu của bạn với trình duyệt chống phát hiện chất lượng cao nhất trên thị trường.
Bạn có muốn thử Octo Browser với giá giảm không?
Sử dụng mã khuyến mãi OCTOBLOG để được giảm giá 30% cho bất kỳ gói đăng ký nào. Ưu đãi này chỉ dành cho người dùng mới.
Hai nhiệm vụ, hai cách tiếp cận
Trước khi chọn phương pháp tự động hóa, hãy xác định xem bạn thực sự đang cố gắng hoàn thành điều gì:
Tải lên video hàng loạt lên một kênh. Hai trăm video cho một kênh có các bài giảng giáo dục, lưu trữ hội thảo trên web, các đoạn cắt podcast. Ở đây, nút thắt cổ chai là giới hạn hàng ngày của chính kênh đó, như chúng ta sẽ thấy bên dưới, không thể bỏ qua.
Làm việc với nhiều kênh. Các phiên bản được bản địa hóa của cùng một nội dung cho các quốc gia khác nhau, các kênh cho các ưu đãi khác nhau, các kênh khách hàng được quản lý bởi một đại lý. Ở đây, câu hỏi chính là làm thế nào để giữ hai mươi tài khoản Google đủ tách biệt để YouTube không liên kết chúng lại với nhau và cấm tất cả chúng cùng một lúc.
Nhiệm vụ đầu tiên được giải quyết thông qua lập kế hoạch và lên lịch xuất bản. Nhiệm vụ thứ hai yêu cầu cô lập cấu hình và tự động hóa. Hãy cùng xem xét cả hai cách tiếp cận.
Khả năng tích hợp sẵn của YouTube để tải lên hàng loạt
Trước khi viết mã script, hãy kiểm tra xem chức năng tích hợp sẵn của YouTube đã đủ cho nhu cầu của bạn chưa. YouTube Studio chấp nhận nhiều tệp cùng một lúc: trong hộp thoại tải lên, bạn có thể chọn một loạt video, tất cả chúng sẽ được thêm dưới dạng bản nháp, sau đó bạn có thể chỉnh sửa hàng loạt siêu dữ liệu của chúng bằng cách chọn nhiều hộp kiểm. Việc lên lịch xuất bản cũng có thể được định cấu hình tại đó, vì vậy hai mươi bản nháp có thể được lên lịch cho cả tháng tới.
Đối với công việc thông thường với một kênh duy nhất, điều này thường là đủ. Tự động hóa bắt đầu mang lại hiệu quả khi bạn có nhiều hơn năm mươi video, nhiều hơn ba kênh hoặc khi siêu dữ liệu được tạo theo lập trình và được lưu trữ trong tệp CSV hoặc cơ sở dữ liệu.
YouTube Data API v3 và hai mức giới hạn trần
Cách chính thức để tải video lên là phương thức videos.insert. Nó hỗ trợ tải lên có thể tiếp tục, chấp nhận các tệp lên đến 256 GB và chỉ hoạt động thông qua OAuth 2.0. Tài khoản dịch vụ không phù hợp để tải lên thay mặt cho một kênh, điều này làm hỏng ngay nỗ lực đầu tiên nhằm xây dựng một trình tải lên phía máy chủ mà không có ủy quyền tương tác: bạn sẽ phải lấy mã thông báo làm mới theo cách thủ công ít nhất một lần cho mỗi kênh.
Tuy nhiên, việc ủy quyền không phải là vấn đề chính.
Hạn ngạch: hầu như mọi hướng dẫn về chủ đề này đều đã lỗi thời
Nếu bạn đã tìm hiểu về việc tải lên hàng loạt trước đây, bạn gần như chắc chắn đã thấy phép tính sau: "10.000 đơn vị chia cho 1.600 cho mỗi lần tải lên tương đương với sáu video mỗi ngày." Phép tính này không còn giá trị nữa và đây là điều quan trọng nhất cần hiểu vào năm 2026.
Google đã thay đổi mô hình hạn ngạch hai lần. Vào ngày 4 tháng 12 năm 2025, chi phí tải lên video đã giảm từ khoảng 1.600 đơn vị xuống còn khoảng 100. Sau đó, vào ngày 1 tháng 6 năm 2026, API đã chuyển sang hệ thống hạn ngạch chi tiết: các lệnh gọi videos.insert và search.list được khấu trừ từ các nhóm riêng biệt của chúng chứ không phải từ một nhóm chung duy nhất.
Hạn ngạch dự án mặc định hiện tại trông như thế này:
Nhóm | Giới hạn mặc định |
|---|---|
| 100 cuộc gọi mỗi ngày |
| 100 cuộc gọi mỗi ngày |
Tất cả các phương thức khác | Tổng cộng 10.000 đơn vị mỗi ngày |
Các nhóm hoạt động độc lập: một khi bạn đã sử dụng hết một trăm lượt tải lên của mình, bạn sẽ nhận được lỗi 403 từ videos.insert, ngay cả khi 9.000 đơn vị vẫn chưa được chạm tới trong nhóm chung.
Chi phí của các phương thức trong nhóm chung không thay đổi:
lượt đọc tốn 1 đơn vị;
lượt ghi như
videos.updatevàthumbnails.settốn 50;captions.insertđắt hơn đáng kể ở mức 400.
Hãy cẩn thận với tài liệu của Google: tại thời điểm viết bài, nó tự mâu thuẫn. Trang "Quota calculator" đã được cập nhật vào tháng 8 năm 2025 và vẫn hiển thị chi phí 1.600 đơn vị cũ cho videos.insert. Bản tổng quan bằng tiếng Anh và mục nhập ngày 1 tháng 6 năm 2026 trong Lịch sử sửa đổi là các nguồn thông tin cập nhật. Bạn nên luôn kiểm tra các giới hạn thực tế của dự án trong Google Cloud Console: các giá trị hạn ngạch có thể khác nhau giữa các dự án cũ hơn và mới hơn.
Kết luận: một trăm lượt tải lên mỗi ngày cho mỗi dự án không còn là nút thắt cổ chai đòi hỏi các giải pháp thay thế nữa. Những hạn chế thực sự nằm ở chỗ khác.
Giới hạn trần thứ nhất: kiểm duyệt và khóa riêng tư
Hãy nói về vấn đề thường khiến các trình tải lên tùy chỉnh trở nên vô dụng nhất, bất kể thay đổi về hạn ngạch. Tất cả các video được tải lên bằng videos.insert từ một dự án API chưa được xác minh được tạo sau ngày 28 tháng 7 năm 2020 sẽ bị buộc chuyển sang chế độ riêng tư. Chủ sở hữu kênh sẽ nhận được một email thông báo rằng video đã bị khóa ở chế độ riêng tư vì nó được tải lên thông qua một ứng dụng khách chưa được xác minh.
Điều này không thể kháng cáo. Lối thoát duy nhất là tải lại video thông qua một ứng dụng khách chính thức hoặc đã được kiểm duyệt, hoặc thông qua trang web YouTube. Vì vậy, cho đến khi quá trình kiểm duyệt được thông qua, trình tải lên vẫn hoạt động về mặt hình thức, trả về 200 OK và cung cấp cho bạn ID video, nhưng không ai ngoại trừ bạn có thể xem kết quả công việc của nó.
Các dự án được tạo trước tháng 7 năm 2020 không phải tuân theo quy tắc này. Nếu bạn có một dự án như vậy, bạn đã gặp may. Những người khác sẽ phải vượt qua vòng kiểm duyệt hoặc từ bỏ việc tải lên bằng API công khai.
Giới hạn trần thứ hai: giới hạn tải lên hàng ngày của kênh
Đây là hạn chế khó chịu nhất vì hoàn toàn không có cách nào để khắc phục. YouTube giới hạn số lượng video mà một kênh có thể tải lên trong vòng 24 giờ và không công bố số liệu chính xác. Chúng ta biết rằng giới hạn này phụ thuộc vào lịch sử của kênh, khu vực và việc kênh đó có bị cảnh cáo hay không, đồng thời nó được áp dụng đồng thời cho phiên bản web, ứng dụng di động và API. Dựa trên các quan sát của cộng đồng, giới hạn này là khoảng 10–20 video đối với các kênh mới và lên đến một trăm đối với các kênh đã thiết lập lâu năm, nhưng đây là những quan sát chứ không phải các giá trị được ghi trong tài liệu.
Trong API, giới hạn này xuất hiện dưới dạng lỗi uploadLimitExceeded riêng biệt với mã 400, và điều quan trọng là phải phân biệt lỗi này với hạn ngạch dự án đã cạn kiệt: lỗi thứ nhất áp dụng cho kênh, trong khi lỗi thứ hai áp dụng cho dự án.
Nếu bạn chạm giới hạn của kênh, bạn sẽ phải đợi 24 giờ. Thay đổi phương thức tải lên hoặc liên hệ với bộ phận hỗ trợ sẽ không giúp ích được gì. Cách hiệu quả duy nhất để tăng hiệu suất không phải là tải lên nhiều hơn cho một kênh, mà là phân bổ khối lượng công việc trên nhiều kênh.
Kết luận về kiến trúc
Theo mô hình hạn ngạch mới, câu hỏi chính không còn là số lượng yêu cầu API nữa. Một trăm lượt tải lên mỗi ngày cho mỗi dự án là đủ cho hầu hết các tác vụ. Tuy nhiên, việc tải lên qua API trước khi vượt qua vòng kiểm duyệt là vô dụng đối với nội dung công khai và một kênh duy nhất không thể mở rộng quy mô theo chiều dọc bất kể phương thức tải lên là gì. Điều đó chỉ để lại việc mở rộng quy mô theo chiều ngang: nhiều kênh hơn, ít lượt tải lên hơn cho mỗi kênh.
Và khi bạn đang quản lý hai mươi kênh, bạn cần một chiến lược đa tài khoản có cấu trúc. Google liên kết các tài khoản mạnh mẽ hơn hầu hết các nền tảng: thông qua vân tay thiết bị, các phiên cookie được chia sẻ, so khớp Client Hints và địa chỉ IP. Hai mươi kênh từ một phiên bản Chrome dưới một IP sẽ được liên kết thành một cụm duy nhất, sau đó có thể bị khóa toàn bộ.
Vì vậy, kiến trúc của giải pháp trông như thế này:
Octo Browser, một trình duyệt chống phát hiện cho mục đích đa tài khoản: một hồ sơ riêng biệt cho mỗi kênh, mỗi hồ sơ có một vân tay và proxy riêng. Các hồ sơ chứa các phiên Google trực tiếp, vì vậy các hồ sơ sử dụng một lần không phù hợp ở đây; bạn cần các hồ sơ cố định. Chúng có thể được sắp xếp thuận tiện vào các thư mục để các kênh thuộc cùng một dự án được nhóm lại với nhau.
Rebrowser-puppeteer: một bản phân nhánh của Puppeteer không có các rò rỉ tự động hóa đặc trưng.
Octo Local API: khởi chạy các hồ sơ và kết nối với chúng qua CDP. Bạn có thể xem mô tả đầy đủ về các phương thức trong tài liệu và tham chiếu API.
Hàng đợi công việc JSON: một tệp chứa danh sách các hồ sơ, các video được liên kết của chúng và siêu dữ liệu. Điều này giữ cho script mang tính phổ quát trong khi tất cả các chi tiết cụ thể được lấy từ dữ liệu đầu vào.
Cách làm việc với các bộ chọn của Studio
Giao diện YouTube Studio được xây dựng bằng các thành phần web Polymer và chiến lược bộ chọn ở đây khác với những gì chúng ta đã sử dụng để thu thập dữ liệu Instagram.
Tin xấu: các lớp (class) trong Studio được tạo tự động và có thời gian tồn tại ngắn. Tin tốt: các thành phần chính có ID và thuộc tính ổn định không bị ràng buộc với ngôn ngữ giao diện. Nút để chuyển sang bước tiếp theo luôn là #next-button. Các nút radio hiển thị là tp-yt-paper-radio-button[name="PRIVATE"], [name="UNLISTED"], [name="PUBLIC"]. Nút chuyển đổi cho nội dung dành cho trẻ em là [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]. Không có cài đặt nào trong số này thay đổi khi ngôn ngữ tài khoản thay đổi.
Bài học thực tế: hãy dựa vào các thuộc tính và ID thay vì văn bản hiển thị trên nút. Một script tìm kiếm một nút có chứa từ "Next" sẽ bị lỗi ngay khi chạy trên một hồ sơ có ngôn ngữ giao diện khác. Một script sử dụng #next-button sẽ hoạt động ở bất kỳ ngôn ngữ nào và không cần phải buộc đặt ngôn ngữ thông qua ?hl=en.
Mẹo thứ hai liên quan đến chính việc tải tệp lên. Nhấp vào vùng kéo và thả là vô ích: nó sẽ mở hộp thoại gốc của hệ điều hành, hộp thoại này không thể đóng được từ trình duyệt. Thay vào đó, tệp được chuyển trực tiếp đến phần tử ẩn input[type="file"] thông qua uploadFile(). Hộp thoại hệ điều hành hoàn toàn không xuất hiện trong trường hợp này.
Script có sẵn để tải video lên nhiều kênh
Script lần lượt đi qua các hồ sơ Octo Browser, mở Studio trong mỗi hồ sơ và tải lên các video được chỉ định cho hồ sơ đó: nó điền tiêu đề, mô tả và thẻ, đánh dấu nội dung là không dành cho trẻ em, đặt chế độ hiển thị và đợi quá trình xử lý hoàn tất. Giữa các lần tải lên, nó thực hiện các khoảng dừng ngẫu nhiên, đếm số lần tải lên cho mỗi kênh và dừng lại khi đạt đến giới hạn hàng ngày được định cấu hình.
Chuẩn bị hồ sơ
Tạo một hồ sơ Octo Browser cho mỗi kênh và gán cho mỗi hồ sơ proxy riêng. Nên sử dụng proxy dân cư hoặc di động. Trước khi sử dụng, bạn nên kiểm tra độ tin cậy của nó.
Mở từng hồ sơ theo cách thủ công và đăng nhập vào tài khoản Google tương ứng. Truy cập Studio, chấp nhận tất cả các lời nhắc ban đầu và xác nhận số điện thoại nếu cần. Script không nên gặp phải quy trình hướng dẫn ban đầu (onboarding).
Tải lên một video theo cách thủ công từ mỗi hồ sơ. Điều này xác nhận rằng kênh đã sẵn sàng chấp nhận các lượt tải lên và cũng giải quyết mọi vấn đề xác minh.
Sao chép các UUID hồ sơ từ Octo Browser. Bạn sẽ cần chúng cho tệp hàng đợi.
Tệp hàng đợi
Tạo tệp upload_queue.json bên cạnh script với cấu trúc sau:
[ { "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" } ] } ]
Trường visibility chấp nhận ba giá trị: PRIVATE, UNLISTED, và PUBLIC. Nếu bạn tạo hàng đợi từ bảng tính hoặc cơ sở dữ liệu, chỉ cần xuất nó ở cùng định dạng.
Chạy script
Tải xuống và cài đặt VS Code.
Tải xuống và cài đặt Node.js.
Tạo một thư mục, ví dụ
octo_youtube_uploader, và mở nó trong VS Code.Tạo tệp
octo_youtube_uploader.jsvà dán mã script vào đó.Đặt tệp
upload_queue.jsoncùng với các hồ sơ và video của bạn bên cạnh nó.Kiểm tra các tham số trong biến
config. Các tham số chính làdaily_limit_per_channel(số lượng video cần tải lên một kênh cho mỗi lần chạy),delay_between_uploads(khoảng dừng giữa các lần tải lên tính bằng giây) vàupload_timeout_min(số phút cần chờ để một video hoàn tất xử lý).Mở terminal và chạy lệnh
npm i rebrowser-puppeteer axios.Khởi chạy Octo Browser.
Chạy script (Ctrl/Cmd + F5) và theo dõi tiến trình trong bảng điều khiển console.
Nếu VS Code hiển thị lỗi khi cài đặt các phần phụ thuộc, hãy mở PowerShell với tư cách quản trị viên, chạy lệnh
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, xác nhận và lặp lại quá trình cài đặt.
Hãy nhớ rằng việc tải lên và xử lý một video mất vài phút chứ không phải vài giây. Một lần chạy liên quan đến hai mươi video có thể mất vài giờ và điều đó là bình thường. Không rút ngắn các khoảng dừng để tăng tốc: các khoảng thời gian ngắn, nhất quán giữa các hành động chính là điều làm lộ ra các mẫu tự động hóa.
Mã script
const axios = require('axios'); const puppeteer = require('rebrowser-puppeteer'); const fs = require('fs').promises; const path = require('path'); const config = { octo_local_api_base_url: 'http://localhost:58888/api/profiles', headless_mode: false, queue_file: 'upload_queue.json', results_dir: 'youtube_results', daily_limit_per_channel: 8, upload_timeout_min: 40, // These two pauses account for roughly 90% of the total run time. // Shortening them speeds things up but makes the pattern easier to // spot, so treat it as a risk decision rather than an optimization. delay_between_uploads: { min: 180, max: 420 }, delay_between_profiles: { min: 120, max: 300 } }; const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; const SELECTORS = { file: 'input[type="file"]', title: ['#title-textarea #textbox', 'ytcp-social-suggestions-textbox#title-textarea #textbox'], description: ['#description-textarea #textbox', 'ytcp-video-description #textbox'], tags_toggle: '#toggle-button', tags_input: '#tags-container input', not_for_kids: 'tp-yt-paper-radio-button[name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]', next: '#next-button', done: '#done-button', progress: '.progress-label', dialogs: ['#close-button', 'ytcp-dialog #dismiss-button'] }; const random_range = (min, max) => min + Math.random() * (max - min); const sleep = seconds => new Promise(r => setTimeout(r, seconds * 1000)); const human_delay = (min_ms = 80, max_ms = 300) => sleep(random_range(min_ms, max_ms) / 1000); async function check_limits(response) { for (const entry of (response.headers.ratelimit || '').split(',')) { const left = entry.match(/;r=(\d+)/); const reset = entry.match(/;t=(\d+)/); if (left && reset && +left[1] < 5) { const wait = +reset[1] + 1; console.log(`⏳ Octo rate limit reached, waiting ${wait}s`); await sleep(wait); } } } async function octo_call(action, body) { const res = await axios.post(`${config.octo_local_api_base_url}/${action}`, body, { headers: { 'Content-Type': 'application/json' } }); await check_limits(res); return res.data; } // Resolves all selectors in a single round trip instead of one call per // selector: on a 0.5s poll that adds up over a long run. async function wait_for_any(page, selectors, timeout = 30000) { const list = Array.isArray(selectors) ? selectors : [selectors]; const started = Date.now(); while (Date.now() - started < timeout) { const hit = await page.evaluate(sels => sels.find(s => { const el = document.querySelector(s); if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }), list); if (hit) return await page.$(hit); await sleep(0.5); } throw new Error(`None of the selectors matched: ${list.join(' | ')}`); } async function type_into_box(page, handle, text) { await handle.click(); await human_delay(200, 500); await page.keyboard.down(MOD_KEY); await page.keyboard.press('KeyA'); await page.keyboard.up(MOD_KEY); await page.keyboard.press('Backspace'); await human_delay(150, 400); for (const chunk of text.split(/(?<=\s)/)) { await page.keyboard.type(chunk, { delay: random_range(15, 55) }); if (Math.random() < 0.15) await human_delay(200, 700); } } async function dismiss_dialogs(page) { for (const selector of SELECTORS.dialogs) { const handle = await page.$(selector); if (!handle) continue; await handle.click().catch(() => { }); await human_delay(500, 1200); } } // Closes tabs we do not need. Studio opens some on its own, and Octo // persists the session, so leftovers reappear on the next launch. // Without `keep` the last surviving tab is parked on about:blank. async function prune_tabs(browser, keep = null) { try { const pages = await browser.pages(); const survivor = keep || pages[0]; for (const p of pages) { if (p !== survivor) await p.close().catch(() => { }); } if (!keep && survivor) { await survivor.goto('about:blank', { timeout: 15000 }).catch(() => { }); } } catch (e) { console.log(` ⚠️ Tab cleanup failed: ${e.message}`); } } async function wait_for_processing(page, timeout_min) { const deadline = Date.now() + timeout_min * 60 * 1000; let last_report = 0; while (Date.now() < deadline) { const percent = await page.evaluate(sel => { const el = document.querySelector(sel); const m = el && el.textContent.match(/(\d+)\s*%/); return m ? Number(m[1]) : null; }, SELECTORS.progress); // The percentage disappearing from the label means the upload is // done and Studio switched to its processing or checks message. if (percent === null || percent >= 100) return true; if (percent - last_report >= 10) { console.log(` ⬆️ Uploaded ${percent}%`); last_report = percent; } await sleep(5); } return false; } async function fill_metadata(page, video) { const title_box = await wait_for_any(page, SELECTORS.title, 90000); await human_delay(1500, 3000); await type_into_box(page, title_box, video.title); console.log(` ✏️ Title: ${video.title}`); if (video.description?.trim()) { const box = await wait_for_any(page, SELECTORS.description); await type_into_box(page, box, video.description); console.log(' 📝 Description filled in'); } if (video.tags?.length) { const toggle = await page.$(SELECTORS.tags_toggle); if (toggle) { await toggle.click(); await human_delay(1000, 2000); const input = await page.$(SELECTORS.tags_input); if (input) { await input.click(); for (const tag of video.tags) { await page.keyboard.type(tag, { delay: random_range(20, 60) }); await page.keyboard.press('Comma'); await human_delay(300, 800); } console.log(` 🏷 Tags: ${video.tags.join(', ')}`); } } } const not_for_kids = await page.$(SELECTORS.not_for_kids); if (not_for_kids) { await not_for_kids.click(); await human_delay(600, 1400); } } async function upload_one(page, video) { console.log(`\n🎬 ${path.basename(video.file)}`); await page.goto('https://www.youtube.com/upload', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(2000, 4000); await dismiss_dialogs(page); const file_input = await page.waitForSelector(SELECTORS.file, { timeout: 45000 }); await file_input.uploadFile(video.file); console.log(' 📤 File handed to the input, waiting for the metadata dialog'); await fill_metadata(page, video); // Three wizard steps: details, video elements, checks. for (let step = 0; step < 3; step++) { const next = await wait_for_any(page, SELECTORS.next); await next.click(); await human_delay(1500, 3000); } const visibility = video.visibility || 'PRIVATE'; const radio = await wait_for_any(page, `tp-yt-paper-radio-button[name="${visibility}"]`); await radio.click(); console.log(` 👁 Visibility: ${visibility}`); await human_delay(1000, 2000); if (!await wait_for_processing(page, config.upload_timeout_min)) { console.log(' ⚠️ Processing timed out, the video stays a draft'); return { file: video.file, success: false, reason: 'processing_timeout' }; } const done = await wait_for_any(page, SELECTORS.done, 60000); await done.click(); await human_delay(3000, 6000); await dismiss_dialogs(page); await prune_tabs(page.browser(), page); console.log(' ✅ Published'); return { file: video.file, success: true, visibility }; } async function process_profile(entry, index, total) { const line = '='.repeat(70); console.log(`\n${line}\n📋 Profile ${index + 1}/${total} — ${entry.channel} (${entry.uuid})`); console.log(` Videos queued: ${entry.videos.length}\n${line}`); const stats = { channel: entry.channel, uuid: entry.uuid, status: 'ok', uploads: [] }; await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(3); let start_data; try { start_data = await octo_call('start', { uuid: entry.uuid, headless: config.headless_mode, debug_port: true, timeout: 120 }); } catch (e) { const body = e.response?.data ? JSON.stringify(e.response.data) : e.message; console.error(`❌ Profile failed to start: ${body}`); return { ...stats, status: 'start_failed' }; } if (!start_data?.ws_endpoint) { console.error('❌ Octo returned no ws_endpoint'); await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); return { ...stats, status: 'no_ws' }; } let browser; try { browser = await puppeteer.connect({ browserWSEndpoint: start_data.ws_endpoint, defaultViewport: null, protocolTimeout: 3600000 }); // The profile starts with a tab already open. Reuse it, otherwise // a second, empty one shows up next to it. const pages = await browser.pages(); const page = pages[0] || await browser.newPage(); await page.setViewport({ width: 1440, height: 900 }); await page.goto('https://studio.youtube.com/', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(1500, 2500); if (page.url().includes('accounts.google.com')) { console.error('❌ Profile is not signed in to Google'); return { ...stats, status: 'not_logged_in' }; } const limit = Math.min(entry.videos.length, config.daily_limit_per_channel); for (let i = 0; i < limit; i++) { try { stats.uploads.push(await upload_one(page, entry.videos[i])); } catch (e) { console.error(`❌ Upload error: ${e.message}`); stats.uploads.push({ file: entry.videos[i].file, success: false, reason: e.message }); } if (i < limit - 1) { const pause = random_range(config.delay_between_uploads.min, config.delay_between_uploads.max); console.log(`⏰ Pause before the next video: ${Math.round(pause)}s`); await sleep(pause); } } if (entry.videos.length > limit) { console.log(`ℹ️ Left in the queue for tomorrow: ${entry.videos.length - limit}`); } } catch (e) { console.error(`❌ Profile processing error: ${e.message}`); stats.status = 'error'; stats.error = e.message; } finally { if (browser) { await prune_tabs(browser); browser.disconnect(); } await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(2); } return stats; } // Validate the queue before launching a browser. An empty title or a wrong // file path would otherwise pass silently: there is nothing to type, no // error is raised, and the video lands on YouTube named after its file. async function validate_queue(queue) { const VISIBILITY = ['PRIVATE', 'UNLISTED', 'PUBLIC']; if (!Array.isArray(queue) || !queue.length) return ['the queue is empty or is not an array']; const problems = []; for (const [i, entry] of queue.entries()) { const where = `entry ${i + 1} (${entry.channel || 'unnamed'})`; if (!entry.uuid) problems.push(`${where}: no profile uuid`); if (!Array.isArray(entry.videos) || !entry.videos.length) { problems.push(`${where}: no videos at all`); continue; } for (const [j, video] of entry.videos.entries()) { const at = `${where}, video ${j + 1}`; const readable = video.file && await fs.access(video.file).then(() => true, () => false); if (!video.file) problems.push(`${at}: no file path`); else if (!readable) problems.push(`${at}: file not found — ${video.file}`); if (!video.title?.trim()) problems.push(`${at}: empty title`); else if (video.title.length > 100) problems.push(`${at}: title over 100 chars (${video.title.length})`); if (video.description?.length > 5000) problems.push(`${at}: description over 5000 chars`); if (video.visibility && !VISIBILITY.includes(video.visibility)) { problems.push(`${at}: visibility "${video.visibility}", allowed: ${VISIBILITY.join(', ')}`); } } } return problems; } (async () => { console.log('🚀 Octo YouTube Bulk Uploader'); const queue = JSON.parse(await fs.readFile(path.join(__dirname, config.queue_file), 'utf8')); const problems = await validate_queue(queue); if (problems.length) { console.error(`\n❌ Queue validation failed (${problems.length}):`); for (const p of problems) console.error(` • ${p}`); console.error('\nFix the queue and run again. No browser was launched.'); process.exit(1); } const total_videos = queue.reduce((sum, e) => sum + e.videos.length, 0); console.log(` ✅ Queue validated — ${queue.length} channels, ${total_videos} videos`); console.log(` Per-channel limit for this run: ${config.daily_limit_per_channel}\n`); const results_dir = path.join(__dirname, config.results_dir); await fs.mkdir(results_dir, { recursive: true }); const all_stats = []; for (let i = 0; i < queue.length; i++) { all_stats.push(await process_profile(queue[i], i, queue.length)); if (i < queue.length - 1) { const pause = random_range(config.delay_between_profiles.min, config.delay_between_profiles.max); console.log(`\n⏰ Pause before the next profile: ${Math.round(pause)}s`); await sleep(pause); } } console.log(`\n${'='.repeat(70)}\n📊 SUMMARY\n${'='.repeat(70)}`); for (const s of all_stats) { const ok = s.uploads.filter(u => u.success).length; console.log(`\n${s.channel} → ${s.status}, succeeded ${ok}/${s.uploads.length}`); for (const u of s.uploads) { console.log(u.success ? ` ✅ ${path.basename(u.file)} → ${u.visibility}` : ` ❌ ${path.basename(u.file)}: ${u.reason}`); } } const report_path = path.join(results_dir, `report_${Date.now()}.json`); await fs.writeFile(report_path, JSON.stringify(all_stats, null, 2), 'utf8'); console.log(`\n📄 Report: ${report_path}\n🎉 Done.`); })();
const axios = require('axios'); const puppeteer = require('rebrowser-puppeteer'); const fs = require('fs').promises; const path = require('path'); const config = { octo_local_api_base_url: 'http://localhost:58888/api/profiles', headless_mode: false, queue_file: 'upload_queue.json', results_dir: 'youtube_results', daily_limit_per_channel: 8, upload_timeout_min: 40, // These two pauses account for roughly 90% of the total run time. // Shortening them speeds things up but makes the pattern easier to // spot, so treat it as a risk decision rather than an optimization. delay_between_uploads: { min: 180, max: 420 }, delay_between_profiles: { min: 120, max: 300 } }; const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; const SELECTORS = { file: 'input[type="file"]', title: ['#title-textarea #textbox', 'ytcp-social-suggestions-textbox#title-textarea #textbox'], description: ['#description-textarea #textbox', 'ytcp-video-description #textbox'], tags_toggle: '#toggle-button', tags_input: '#tags-container input', not_for_kids: 'tp-yt-paper-radio-button[name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]', next: '#next-button', done: '#done-button', progress: '.progress-label', dialogs: ['#close-button', 'ytcp-dialog #dismiss-button'] }; const random_range = (min, max) => min + Math.random() * (max - min); const sleep = seconds => new Promise(r => setTimeout(r, seconds * 1000)); const human_delay = (min_ms = 80, max_ms = 300) => sleep(random_range(min_ms, max_ms) / 1000); async function check_limits(response) { for (const entry of (response.headers.ratelimit || '').split(',')) { const left = entry.match(/;r=(\d+)/); const reset = entry.match(/;t=(\d+)/); if (left && reset && +left[1] < 5) { const wait = +reset[1] + 1; console.log(`⏳ Octo rate limit reached, waiting ${wait}s`); await sleep(wait); } } } async function octo_call(action, body) { const res = await axios.post(`${config.octo_local_api_base_url}/${action}`, body, { headers: { 'Content-Type': 'application/json' } }); await check_limits(res); return res.data; } // Resolves all selectors in a single round trip instead of one call per // selector: on a 0.5s poll that adds up over a long run. async function wait_for_any(page, selectors, timeout = 30000) { const list = Array.isArray(selectors) ? selectors : [selectors]; const started = Date.now(); while (Date.now() - started < timeout) { const hit = await page.evaluate(sels => sels.find(s => { const el = document.querySelector(s); if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }), list); if (hit) return await page.$(hit); await sleep(0.5); } throw new Error(`None of the selectors matched: ${list.join(' | ')}`); } async function type_into_box(page, handle, text) { await handle.click(); await human_delay(200, 500); await page.keyboard.down(MOD_KEY); await page.keyboard.press('KeyA'); await page.keyboard.up(MOD_KEY); await page.keyboard.press('Backspace'); await human_delay(150, 400); for (const chunk of text.split(/(?<=\s)/)) { await page.keyboard.type(chunk, { delay: random_range(15, 55) }); if (Math.random() < 0.15) await human_delay(200, 700); } } async function dismiss_dialogs(page) { for (const selector of SELECTORS.dialogs) { const handle = await page.$(selector); if (!handle) continue; await handle.click().catch(() => { }); await human_delay(500, 1200); } } // Closes tabs we do not need. Studio opens some on its own, and Octo // persists the session, so leftovers reappear on the next launch. // Without `keep` the last surviving tab is parked on about:blank. async function prune_tabs(browser, keep = null) { try { const pages = await browser.pages(); const survivor = keep || pages[0]; for (const p of pages) { if (p !== survivor) await p.close().catch(() => { }); } if (!keep && survivor) { await survivor.goto('about:blank', { timeout: 15000 }).catch(() => { }); } } catch (e) { console.log(` ⚠️ Tab cleanup failed: ${e.message}`); } } async function wait_for_processing(page, timeout_min) { const deadline = Date.now() + timeout_min * 60 * 1000; let last_report = 0; while (Date.now() < deadline) { const percent = await page.evaluate(sel => { const el = document.querySelector(sel); const m = el && el.textContent.match(/(\d+)\s*%/); return m ? Number(m[1]) : null; }, SELECTORS.progress); // The percentage disappearing from the label means the upload is // done and Studio switched to its processing or checks message. if (percent === null || percent >= 100) return true; if (percent - last_report >= 10) { console.log(` ⬆️ Uploaded ${percent}%`); last_report = percent; } await sleep(5); } return false; } async function fill_metadata(page, video) { const title_box = await wait_for_any(page, SELECTORS.title, 90000); await human_delay(1500, 3000); await type_into_box(page, title_box, video.title); console.log(` ✏️ Title: ${video.title}`); if (video.description?.trim()) { const box = await wait_for_any(page, SELECTORS.description); await type_into_box(page, box, video.description); console.log(' 📝 Description filled in'); } if (video.tags?.length) { const toggle = await page.$(SELECTORS.tags_toggle); if (toggle) { await toggle.click(); await human_delay(1000, 2000); const input = await page.$(SELECTORS.tags_input); if (input) { await input.click(); for (const tag of video.tags) { await page.keyboard.type(tag, { delay: random_range(20, 60) }); await page.keyboard.press('Comma'); await human_delay(300, 800); } console.log(` 🏷 Tags: ${video.tags.join(', ')}`); } } } const not_for_kids = await page.$(SELECTORS.not_for_kids); if (not_for_kids) { await not_for_kids.click(); await human_delay(600, 1400); } } async function upload_one(page, video) { console.log(`\n🎬 ${path.basename(video.file)}`); await page.goto('https://www.youtube.com/upload', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(2000, 4000); await dismiss_dialogs(page); const file_input = await page.waitForSelector(SELECTORS.file, { timeout: 45000 }); await file_input.uploadFile(video.file); console.log(' 📤 File handed to the input, waiting for the metadata dialog'); await fill_metadata(page, video); // Three wizard steps: details, video elements, checks. for (let step = 0; step < 3; step++) { const next = await wait_for_any(page, SELECTORS.next); await next.click(); await human_delay(1500, 3000); } const visibility = video.visibility || 'PRIVATE'; const radio = await wait_for_any(page, `tp-yt-paper-radio-button[name="${visibility}"]`); await radio.click(); console.log(` 👁 Visibility: ${visibility}`); await human_delay(1000, 2000); if (!await wait_for_processing(page, config.upload_timeout_min)) { console.log(' ⚠️ Processing timed out, the video stays a draft'); return { file: video.file, success: false, reason: 'processing_timeout' }; } const done = await wait_for_any(page, SELECTORS.done, 60000); await done.click(); await human_delay(3000, 6000); await dismiss_dialogs(page); await prune_tabs(page.browser(), page); console.log(' ✅ Published'); return { file: video.file, success: true, visibility }; } async function process_profile(entry, index, total) { const line = '='.repeat(70); console.log(`\n${line}\n📋 Profile ${index + 1}/${total} — ${entry.channel} (${entry.uuid})`); console.log(` Videos queued: ${entry.videos.length}\n${line}`); const stats = { channel: entry.channel, uuid: entry.uuid, status: 'ok', uploads: [] }; await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(3); let start_data; try { start_data = await octo_call('start', { uuid: entry.uuid, headless: config.headless_mode, debug_port: true, timeout: 120 }); } catch (e) { const body = e.response?.data ? JSON.stringify(e.response.data) : e.message; console.error(`❌ Profile failed to start: ${body}`); return { ...stats, status: 'start_failed' }; } if (!start_data?.ws_endpoint) { console.error('❌ Octo returned no ws_endpoint'); await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); return { ...stats, status: 'no_ws' }; } let browser; try { browser = await puppeteer.connect({ browserWSEndpoint: start_data.ws_endpoint, defaultViewport: null, protocolTimeout: 3600000 }); // The profile starts with a tab already open. Reuse it, otherwise // a second, empty one shows up next to it. const pages = await browser.pages(); const page = pages[0] || await browser.newPage(); await page.setViewport({ width: 1440, height: 900 }); await page.goto('https://studio.youtube.com/', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(1500, 2500); if (page.url().includes('accounts.google.com')) { console.error('❌ Profile is not signed in to Google'); return { ...stats, status: 'not_logged_in' }; } const limit = Math.min(entry.videos.length, config.daily_limit_per_channel); for (let i = 0; i < limit; i++) { try { stats.uploads.push(await upload_one(page, entry.videos[i])); } catch (e) { console.error(`❌ Upload error: ${e.message}`); stats.uploads.push({ file: entry.videos[i].file, success: false, reason: e.message }); } if (i < limit - 1) { const pause = random_range(config.delay_between_uploads.min, config.delay_between_uploads.max); console.log(`⏰ Pause before the next video: ${Math.round(pause)}s`); await sleep(pause); } } if (entry.videos.length > limit) { console.log(`ℹ️ Left in the queue for tomorrow: ${entry.videos.length - limit}`); } } catch (e) { console.error(`❌ Profile processing error: ${e.message}`); stats.status = 'error'; stats.error = e.message; } finally { if (browser) { await prune_tabs(browser); browser.disconnect(); } await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(2); } return stats; } // Validate the queue before launching a browser. An empty title or a wrong // file path would otherwise pass silently: there is nothing to type, no // error is raised, and the video lands on YouTube named after its file. async function validate_queue(queue) { const VISIBILITY = ['PRIVATE', 'UNLISTED', 'PUBLIC']; if (!Array.isArray(queue) || !queue.length) return ['the queue is empty or is not an array']; const problems = []; for (const [i, entry] of queue.entries()) { const where = `entry ${i + 1} (${entry.channel || 'unnamed'})`; if (!entry.uuid) problems.push(`${where}: no profile uuid`); if (!Array.isArray(entry.videos) || !entry.videos.length) { problems.push(`${where}: no videos at all`); continue; } for (const [j, video] of entry.videos.entries()) { const at = `${where}, video ${j + 1}`; const readable = video.file && await fs.access(video.file).then(() => true, () => false); if (!video.file) problems.push(`${at}: no file path`); else if (!readable) problems.push(`${at}: file not found — ${video.file}`); if (!video.title?.trim()) problems.push(`${at}: empty title`); else if (video.title.length > 100) problems.push(`${at}: title over 100 chars (${video.title.length})`); if (video.description?.length > 5000) problems.push(`${at}: description over 5000 chars`); if (video.visibility && !VISIBILITY.includes(video.visibility)) { problems.push(`${at}: visibility "${video.visibility}", allowed: ${VISIBILITY.join(', ')}`); } } } return problems; } (async () => { console.log('🚀 Octo YouTube Bulk Uploader'); const queue = JSON.parse(await fs.readFile(path.join(__dirname, config.queue_file), 'utf8')); const problems = await validate_queue(queue); if (problems.length) { console.error(`\n❌ Queue validation failed (${problems.length}):`); for (const p of problems) console.error(` • ${p}`); console.error('\nFix the queue and run again. No browser was launched.'); process.exit(1); } const total_videos = queue.reduce((sum, e) => sum + e.videos.length, 0); console.log(` ✅ Queue validated — ${queue.length} channels, ${total_videos} videos`); console.log(` Per-channel limit for this run: ${config.daily_limit_per_channel}\n`); const results_dir = path.join(__dirname, config.results_dir); await fs.mkdir(results_dir, { recursive: true }); const all_stats = []; for (let i = 0; i < queue.length; i++) { all_stats.push(await process_profile(queue[i], i, queue.length)); if (i < queue.length - 1) { const pause = random_range(config.delay_between_profiles.min, config.delay_between_profiles.max); console.log(`\n⏰ Pause before the next profile: ${Math.round(pause)}s`); await sleep(pause); } } console.log(`\n${'='.repeat(70)}\n📊 SUMMARY\n${'='.repeat(70)}`); for (const s of all_stats) { const ok = s.uploads.filter(u => u.success).length; console.log(`\n${s.channel} → ${s.status}, succeeded ${ok}/${s.uploads.length}`); for (const u of s.uploads) { console.log(u.success ? ` ✅ ${path.basename(u.file)} → ${u.visibility}` : ` ❌ ${path.basename(u.file)}: ${u.reason}`); } } const report_path = path.join(results_dir, `report_${Date.now()}.json`); await fs.writeFile(report_path, JSON.stringify(all_stats, null, 2), 'utf8'); console.log(`\n📄 Report: ${report_path}\n🎉 Done.`); })();
Danh sách kiểm tra trước khi chạy toàn bộ hệ thống
Các kênh đã được chuẩn bị đúng cách. Một kênh mới nhận được hai mươi video trong ngày đầu tiên hoạt động sẽ trông rất đáng ngờ, bất kể cách tải lên như thế nào. Hãy bắt đầu với hai hoặc ba lượt tải lên mỗi ngày và phát triển kênh dần dần.
Các proxy khác nhau và không phải loại từ trung tâm dữ liệu (datacenter). Một địa chỉ IP cho hai mươi kênh sẽ làm mất đi tác dụng của việc cô lập hồ sơ.
Siêu dữ liệu không sử dụng chung một khuôn mẫu (template). Các mô tả giống hệt nhau với các tập hợp liên kết giống hệt nhau trên toàn bộ mạng lưới là một trong những dấu hiệu rõ ràng nhất cho thấy các kênh có liên kết với nhau. Hãy tạo ra các biến thể.
Các bộ chọn đã được thử nghiệm. Giao diện Studio thay đổi theo thời gian, vì vậy trước khi thực hiện một lượt chạy lớn, hãy chạy thử script trên một hồ sơ với tùy chọn
headless_mode: falsevà kiểm tra trực quan xem nó có thể bị lỗi ở đâu.Tải lên từng video một cho mỗi hồ sơ. Việc tải lên song song nhiều video từ cùng một hồ sơ bị giới hạn bởi băng thông của kênh và làm tăng khả năng xảy ra lỗi quá thời gian chờ (timeout). Hãy chạy song song các hồ sơ, chứ không phải các video trong cùng một hồ sơ.
Nội dung không bị trùng lặp. Việc tải cùng một tệp lên mười kênh khác nhau có thể vi phạm chính sách nội dung trùng lặp của YouTube. Việc bản địa hóa, chỉnh sửa và lồng tiếng cần phải khác nhau.
Phương pháp tiếp cận kết hợp: tải lên bằng trình duyệt, quản lý bằng API
Phương pháp này trước đây được coi là hợp lý vì lý do chi phí: tải lên qua API tốn 1.600 đơn vị, trong khi mọi hoạt động khác chỉ tốn từ 1 đến 50 đơn vị. Sau khi đợt giảm giá hồi tháng 12 được áp dụng, lập luận về chi phí không còn nữa, nhưng bản thân phương pháp tiếp cận này vẫn hữu ích, chỉ là vì một lý do khác.
Các lượt tải lên qua API từ một dự án chưa được xác minh sẽ khiến video bị khóa ở chế độ riêng tư, trong khi nhóm videos.insert bị giới hạn ở mức tối đa một trăm lệnh gọi mỗi ngày cho mỗi dự án và chỉ có thể mở rộng thông qua quy trình kiểm duyệt. Tất cả các hoạt động khác không phải chịu bất kỳ hạn chế nào trong hai hạn chế này: chúng được khấu trừ từ nhóm 10.000 đơn vị chung, trong đó việc chỉnh sửa siêu dữ liệu tốn 50 đơn vị và đọc siêu dữ liệu tốn một đơn vị.
Điều này dẫn đến sự phân chia trách nhiệm rõ rệt. Các tệp video được tải lên thông qua tự động hóa trình duyệt vì cách này tránh được quy tắc ứng dụng khách chưa xác minh và không tiêu tốn hạn ngạch tải lên vốn rất hạn hẹp. Sau đó, API sẽ tiếp quản: nó chỉnh sửa tiêu đề và mô tả qua videos.update, đặt hình thu nhỏ qua thumbnails.set, sắp xếp video vào danh sách phát và thu thập số liệu thống kê. Không cần kiểm duyệt cho các hoạt động này và không có rủi ro video bị khóa riêng tư vì bản thân việc tải lên đã được thực hiện bởi trình duyệt.
Trình duyệt làm những việc có rủi ro cao nếu thực hiện qua API; API làm những việc chậm chạp và dễ lỗi nếu thực hiện qua trình duyệt. Việc chỉnh sửa hàng loạt mô tả cho hai trăm video qua API chỉ mất một phút, trong khi thực hiện việc đó thông qua tự động hóa Studio trên trình duyệt sẽ mất nửa ngày.
Kết luận
Việc tải lên video YouTube hàng loạt gặp phải nhiều trở ngại chứ không chỉ là giới hạn hạn ngạch. Kể từ cuối năm 2025, hạn ngạch Data API về cơ bản đã không còn là vấn đề lớn: một trăm lượt tải lên mỗi ngày cho mỗi dự án đã đáp ứng được hầu hết các trường hợp sử dụng và giới hạn này có thể được mở rộng thông qua kiểm duyệt. Vấn đề video bị khóa riêng tư do tải lên từ các dự án chưa được xác minh có thể được giải quyết thông qua chính quy trình kiểm duyệt đó hoặc bằng cách tải video lên trực tiếp trong trình duyệt. Giới hạn tải lên hàng ngày của kênh thì hoàn toàn không thể bỏ qua, vì vậy khối lượng công việc cần được phân bổ trên nhiều kênh khác nhau.
Mã script trong bài viết này xử lý trường hợp cuối cùng: nó biến một mạng lưới các kênh thành một quy trình quản lý tập trung duy nhất, trong đó hàng đợi công việc nằm trong tệp JSON và mỗi kênh hoạt động từ một hồ sơ cô lập riêng biệt với proxy riêng. Nó có thể được mở rộng theo nhiều cách rõ ràng: gán hình thu nhỏ, thêm phụ đề, lên lịch xuất bản thay vì xuất bản ngay lập tức, hoặc lưu trạng thái hàng đợi liên tục để lượt chạy có thể tiếp tục từ điểm đã dừng của ngày hôm trước.
Khả năng phục hồi và độ bền bỉ của một hệ thống như vậy không được quyết định bởi mã script (vốn là phần đơn giản nhất). Hệ thống sẽ hoạt động tốt miễn là các kênh xuất hiện độc lập với nhau: vân tay khác nhau, địa chỉ IP khác nhau, nội dung khác nhau và tốc độ xuất bản hợp lý.
Giữ kín danh tính, tận dụng tính năng nhiều tài khoản và đạt được mục tiêu của bạn với trình duyệt chống phát hiện chất lượng cao nhất trên thị trường.
Bạn có muốn thử Octo Browser với giá giảm không?
Sử dụng mã khuyến mãi OCTOBLOG để được giảm giá 30% cho bất kỳ gói đăng ký nào. Ưu đãi này chỉ dành cho người dùng mới.
Hai nhiệm vụ, hai cách tiếp cận
Trước khi chọn phương pháp tự động hóa, hãy xác định xem bạn thực sự đang cố gắng hoàn thành điều gì:
Tải lên video hàng loạt lên một kênh. Hai trăm video cho một kênh có các bài giảng giáo dục, lưu trữ hội thảo trên web, các đoạn cắt podcast. Ở đây, nút thắt cổ chai là giới hạn hàng ngày của chính kênh đó, như chúng ta sẽ thấy bên dưới, không thể bỏ qua.
Làm việc với nhiều kênh. Các phiên bản được bản địa hóa của cùng một nội dung cho các quốc gia khác nhau, các kênh cho các ưu đãi khác nhau, các kênh khách hàng được quản lý bởi một đại lý. Ở đây, câu hỏi chính là làm thế nào để giữ hai mươi tài khoản Google đủ tách biệt để YouTube không liên kết chúng lại với nhau và cấm tất cả chúng cùng một lúc.
Nhiệm vụ đầu tiên được giải quyết thông qua lập kế hoạch và lên lịch xuất bản. Nhiệm vụ thứ hai yêu cầu cô lập cấu hình và tự động hóa. Hãy cùng xem xét cả hai cách tiếp cận.
Khả năng tích hợp sẵn của YouTube để tải lên hàng loạt
Trước khi viết mã script, hãy kiểm tra xem chức năng tích hợp sẵn của YouTube đã đủ cho nhu cầu của bạn chưa. YouTube Studio chấp nhận nhiều tệp cùng một lúc: trong hộp thoại tải lên, bạn có thể chọn một loạt video, tất cả chúng sẽ được thêm dưới dạng bản nháp, sau đó bạn có thể chỉnh sửa hàng loạt siêu dữ liệu của chúng bằng cách chọn nhiều hộp kiểm. Việc lên lịch xuất bản cũng có thể được định cấu hình tại đó, vì vậy hai mươi bản nháp có thể được lên lịch cho cả tháng tới.
Đối với công việc thông thường với một kênh duy nhất, điều này thường là đủ. Tự động hóa bắt đầu mang lại hiệu quả khi bạn có nhiều hơn năm mươi video, nhiều hơn ba kênh hoặc khi siêu dữ liệu được tạo theo lập trình và được lưu trữ trong tệp CSV hoặc cơ sở dữ liệu.
YouTube Data API v3 và hai mức giới hạn trần
Cách chính thức để tải video lên là phương thức videos.insert. Nó hỗ trợ tải lên có thể tiếp tục, chấp nhận các tệp lên đến 256 GB và chỉ hoạt động thông qua OAuth 2.0. Tài khoản dịch vụ không phù hợp để tải lên thay mặt cho một kênh, điều này làm hỏng ngay nỗ lực đầu tiên nhằm xây dựng một trình tải lên phía máy chủ mà không có ủy quyền tương tác: bạn sẽ phải lấy mã thông báo làm mới theo cách thủ công ít nhất một lần cho mỗi kênh.
Tuy nhiên, việc ủy quyền không phải là vấn đề chính.
Hạn ngạch: hầu như mọi hướng dẫn về chủ đề này đều đã lỗi thời
Nếu bạn đã tìm hiểu về việc tải lên hàng loạt trước đây, bạn gần như chắc chắn đã thấy phép tính sau: "10.000 đơn vị chia cho 1.600 cho mỗi lần tải lên tương đương với sáu video mỗi ngày." Phép tính này không còn giá trị nữa và đây là điều quan trọng nhất cần hiểu vào năm 2026.
Google đã thay đổi mô hình hạn ngạch hai lần. Vào ngày 4 tháng 12 năm 2025, chi phí tải lên video đã giảm từ khoảng 1.600 đơn vị xuống còn khoảng 100. Sau đó, vào ngày 1 tháng 6 năm 2026, API đã chuyển sang hệ thống hạn ngạch chi tiết: các lệnh gọi videos.insert và search.list được khấu trừ từ các nhóm riêng biệt của chúng chứ không phải từ một nhóm chung duy nhất.
Hạn ngạch dự án mặc định hiện tại trông như thế này:
Nhóm | Giới hạn mặc định |
|---|---|
| 100 cuộc gọi mỗi ngày |
| 100 cuộc gọi mỗi ngày |
Tất cả các phương thức khác | Tổng cộng 10.000 đơn vị mỗi ngày |
Các nhóm hoạt động độc lập: một khi bạn đã sử dụng hết một trăm lượt tải lên của mình, bạn sẽ nhận được lỗi 403 từ videos.insert, ngay cả khi 9.000 đơn vị vẫn chưa được chạm tới trong nhóm chung.
Chi phí của các phương thức trong nhóm chung không thay đổi:
lượt đọc tốn 1 đơn vị;
lượt ghi như
videos.updatevàthumbnails.settốn 50;captions.insertđắt hơn đáng kể ở mức 400.
Hãy cẩn thận với tài liệu của Google: tại thời điểm viết bài, nó tự mâu thuẫn. Trang "Quota calculator" đã được cập nhật vào tháng 8 năm 2025 và vẫn hiển thị chi phí 1.600 đơn vị cũ cho videos.insert. Bản tổng quan bằng tiếng Anh và mục nhập ngày 1 tháng 6 năm 2026 trong Lịch sử sửa đổi là các nguồn thông tin cập nhật. Bạn nên luôn kiểm tra các giới hạn thực tế của dự án trong Google Cloud Console: các giá trị hạn ngạch có thể khác nhau giữa các dự án cũ hơn và mới hơn.
Kết luận: một trăm lượt tải lên mỗi ngày cho mỗi dự án không còn là nút thắt cổ chai đòi hỏi các giải pháp thay thế nữa. Những hạn chế thực sự nằm ở chỗ khác.
Giới hạn trần thứ nhất: kiểm duyệt và khóa riêng tư
Hãy nói về vấn đề thường khiến các trình tải lên tùy chỉnh trở nên vô dụng nhất, bất kể thay đổi về hạn ngạch. Tất cả các video được tải lên bằng videos.insert từ một dự án API chưa được xác minh được tạo sau ngày 28 tháng 7 năm 2020 sẽ bị buộc chuyển sang chế độ riêng tư. Chủ sở hữu kênh sẽ nhận được một email thông báo rằng video đã bị khóa ở chế độ riêng tư vì nó được tải lên thông qua một ứng dụng khách chưa được xác minh.
Điều này không thể kháng cáo. Lối thoát duy nhất là tải lại video thông qua một ứng dụng khách chính thức hoặc đã được kiểm duyệt, hoặc thông qua trang web YouTube. Vì vậy, cho đến khi quá trình kiểm duyệt được thông qua, trình tải lên vẫn hoạt động về mặt hình thức, trả về 200 OK và cung cấp cho bạn ID video, nhưng không ai ngoại trừ bạn có thể xem kết quả công việc của nó.
Các dự án được tạo trước tháng 7 năm 2020 không phải tuân theo quy tắc này. Nếu bạn có một dự án như vậy, bạn đã gặp may. Những người khác sẽ phải vượt qua vòng kiểm duyệt hoặc từ bỏ việc tải lên bằng API công khai.
Giới hạn trần thứ hai: giới hạn tải lên hàng ngày của kênh
Đây là hạn chế khó chịu nhất vì hoàn toàn không có cách nào để khắc phục. YouTube giới hạn số lượng video mà một kênh có thể tải lên trong vòng 24 giờ và không công bố số liệu chính xác. Chúng ta biết rằng giới hạn này phụ thuộc vào lịch sử của kênh, khu vực và việc kênh đó có bị cảnh cáo hay không, đồng thời nó được áp dụng đồng thời cho phiên bản web, ứng dụng di động và API. Dựa trên các quan sát của cộng đồng, giới hạn này là khoảng 10–20 video đối với các kênh mới và lên đến một trăm đối với các kênh đã thiết lập lâu năm, nhưng đây là những quan sát chứ không phải các giá trị được ghi trong tài liệu.
Trong API, giới hạn này xuất hiện dưới dạng lỗi uploadLimitExceeded riêng biệt với mã 400, và điều quan trọng là phải phân biệt lỗi này với hạn ngạch dự án đã cạn kiệt: lỗi thứ nhất áp dụng cho kênh, trong khi lỗi thứ hai áp dụng cho dự án.
Nếu bạn chạm giới hạn của kênh, bạn sẽ phải đợi 24 giờ. Thay đổi phương thức tải lên hoặc liên hệ với bộ phận hỗ trợ sẽ không giúp ích được gì. Cách hiệu quả duy nhất để tăng hiệu suất không phải là tải lên nhiều hơn cho một kênh, mà là phân bổ khối lượng công việc trên nhiều kênh.
Kết luận về kiến trúc
Theo mô hình hạn ngạch mới, câu hỏi chính không còn là số lượng yêu cầu API nữa. Một trăm lượt tải lên mỗi ngày cho mỗi dự án là đủ cho hầu hết các tác vụ. Tuy nhiên, việc tải lên qua API trước khi vượt qua vòng kiểm duyệt là vô dụng đối với nội dung công khai và một kênh duy nhất không thể mở rộng quy mô theo chiều dọc bất kể phương thức tải lên là gì. Điều đó chỉ để lại việc mở rộng quy mô theo chiều ngang: nhiều kênh hơn, ít lượt tải lên hơn cho mỗi kênh.
Và khi bạn đang quản lý hai mươi kênh, bạn cần một chiến lược đa tài khoản có cấu trúc. Google liên kết các tài khoản mạnh mẽ hơn hầu hết các nền tảng: thông qua vân tay thiết bị, các phiên cookie được chia sẻ, so khớp Client Hints và địa chỉ IP. Hai mươi kênh từ một phiên bản Chrome dưới một IP sẽ được liên kết thành một cụm duy nhất, sau đó có thể bị khóa toàn bộ.
Vì vậy, kiến trúc của giải pháp trông như thế này:
Octo Browser, một trình duyệt chống phát hiện cho mục đích đa tài khoản: một hồ sơ riêng biệt cho mỗi kênh, mỗi hồ sơ có một vân tay và proxy riêng. Các hồ sơ chứa các phiên Google trực tiếp, vì vậy các hồ sơ sử dụng một lần không phù hợp ở đây; bạn cần các hồ sơ cố định. Chúng có thể được sắp xếp thuận tiện vào các thư mục để các kênh thuộc cùng một dự án được nhóm lại với nhau.
Rebrowser-puppeteer: một bản phân nhánh của Puppeteer không có các rò rỉ tự động hóa đặc trưng.
Octo Local API: khởi chạy các hồ sơ và kết nối với chúng qua CDP. Bạn có thể xem mô tả đầy đủ về các phương thức trong tài liệu và tham chiếu API.
Hàng đợi công việc JSON: một tệp chứa danh sách các hồ sơ, các video được liên kết của chúng và siêu dữ liệu. Điều này giữ cho script mang tính phổ quát trong khi tất cả các chi tiết cụ thể được lấy từ dữ liệu đầu vào.
Cách làm việc với các bộ chọn của Studio
Giao diện YouTube Studio được xây dựng bằng các thành phần web Polymer và chiến lược bộ chọn ở đây khác với những gì chúng ta đã sử dụng để thu thập dữ liệu Instagram.
Tin xấu: các lớp (class) trong Studio được tạo tự động và có thời gian tồn tại ngắn. Tin tốt: các thành phần chính có ID và thuộc tính ổn định không bị ràng buộc với ngôn ngữ giao diện. Nút để chuyển sang bước tiếp theo luôn là #next-button. Các nút radio hiển thị là tp-yt-paper-radio-button[name="PRIVATE"], [name="UNLISTED"], [name="PUBLIC"]. Nút chuyển đổi cho nội dung dành cho trẻ em là [name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]. Không có cài đặt nào trong số này thay đổi khi ngôn ngữ tài khoản thay đổi.
Bài học thực tế: hãy dựa vào các thuộc tính và ID thay vì văn bản hiển thị trên nút. Một script tìm kiếm một nút có chứa từ "Next" sẽ bị lỗi ngay khi chạy trên một hồ sơ có ngôn ngữ giao diện khác. Một script sử dụng #next-button sẽ hoạt động ở bất kỳ ngôn ngữ nào và không cần phải buộc đặt ngôn ngữ thông qua ?hl=en.
Mẹo thứ hai liên quan đến chính việc tải tệp lên. Nhấp vào vùng kéo và thả là vô ích: nó sẽ mở hộp thoại gốc của hệ điều hành, hộp thoại này không thể đóng được từ trình duyệt. Thay vào đó, tệp được chuyển trực tiếp đến phần tử ẩn input[type="file"] thông qua uploadFile(). Hộp thoại hệ điều hành hoàn toàn không xuất hiện trong trường hợp này.
Script có sẵn để tải video lên nhiều kênh
Script lần lượt đi qua các hồ sơ Octo Browser, mở Studio trong mỗi hồ sơ và tải lên các video được chỉ định cho hồ sơ đó: nó điền tiêu đề, mô tả và thẻ, đánh dấu nội dung là không dành cho trẻ em, đặt chế độ hiển thị và đợi quá trình xử lý hoàn tất. Giữa các lần tải lên, nó thực hiện các khoảng dừng ngẫu nhiên, đếm số lần tải lên cho mỗi kênh và dừng lại khi đạt đến giới hạn hàng ngày được định cấu hình.
Chuẩn bị hồ sơ
Tạo một hồ sơ Octo Browser cho mỗi kênh và gán cho mỗi hồ sơ proxy riêng. Nên sử dụng proxy dân cư hoặc di động. Trước khi sử dụng, bạn nên kiểm tra độ tin cậy của nó.
Mở từng hồ sơ theo cách thủ công và đăng nhập vào tài khoản Google tương ứng. Truy cập Studio, chấp nhận tất cả các lời nhắc ban đầu và xác nhận số điện thoại nếu cần. Script không nên gặp phải quy trình hướng dẫn ban đầu (onboarding).
Tải lên một video theo cách thủ công từ mỗi hồ sơ. Điều này xác nhận rằng kênh đã sẵn sàng chấp nhận các lượt tải lên và cũng giải quyết mọi vấn đề xác minh.
Sao chép các UUID hồ sơ từ Octo Browser. Bạn sẽ cần chúng cho tệp hàng đợi.
Tệp hàng đợi
Tạo tệp upload_queue.json bên cạnh script với cấu trúc sau:
[ { "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" } ] } ]
Trường visibility chấp nhận ba giá trị: PRIVATE, UNLISTED, và PUBLIC. Nếu bạn tạo hàng đợi từ bảng tính hoặc cơ sở dữ liệu, chỉ cần xuất nó ở cùng định dạng.
Chạy script
Tải xuống và cài đặt VS Code.
Tải xuống và cài đặt Node.js.
Tạo một thư mục, ví dụ
octo_youtube_uploader, và mở nó trong VS Code.Tạo tệp
octo_youtube_uploader.jsvà dán mã script vào đó.Đặt tệp
upload_queue.jsoncùng với các hồ sơ và video của bạn bên cạnh nó.Kiểm tra các tham số trong biến
config. Các tham số chính làdaily_limit_per_channel(số lượng video cần tải lên một kênh cho mỗi lần chạy),delay_between_uploads(khoảng dừng giữa các lần tải lên tính bằng giây) vàupload_timeout_min(số phút cần chờ để một video hoàn tất xử lý).Mở terminal và chạy lệnh
npm i rebrowser-puppeteer axios.Khởi chạy Octo Browser.
Chạy script (Ctrl/Cmd + F5) và theo dõi tiến trình trong bảng điều khiển console.
Nếu VS Code hiển thị lỗi khi cài đặt các phần phụ thuộc, hãy mở PowerShell với tư cách quản trị viên, chạy lệnh
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, xác nhận và lặp lại quá trình cài đặt.
Hãy nhớ rằng việc tải lên và xử lý một video mất vài phút chứ không phải vài giây. Một lần chạy liên quan đến hai mươi video có thể mất vài giờ và điều đó là bình thường. Không rút ngắn các khoảng dừng để tăng tốc: các khoảng thời gian ngắn, nhất quán giữa các hành động chính là điều làm lộ ra các mẫu tự động hóa.
Mã script
const axios = require('axios'); const puppeteer = require('rebrowser-puppeteer'); const fs = require('fs').promises; const path = require('path'); const config = { octo_local_api_base_url: 'http://localhost:58888/api/profiles', headless_mode: false, queue_file: 'upload_queue.json', results_dir: 'youtube_results', daily_limit_per_channel: 8, upload_timeout_min: 40, // These two pauses account for roughly 90% of the total run time. // Shortening them speeds things up but makes the pattern easier to // spot, so treat it as a risk decision rather than an optimization. delay_between_uploads: { min: 180, max: 420 }, delay_between_profiles: { min: 120, max: 300 } }; const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; const SELECTORS = { file: 'input[type="file"]', title: ['#title-textarea #textbox', 'ytcp-social-suggestions-textbox#title-textarea #textbox'], description: ['#description-textarea #textbox', 'ytcp-video-description #textbox'], tags_toggle: '#toggle-button', tags_input: '#tags-container input', not_for_kids: 'tp-yt-paper-radio-button[name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]', next: '#next-button', done: '#done-button', progress: '.progress-label', dialogs: ['#close-button', 'ytcp-dialog #dismiss-button'] }; const random_range = (min, max) => min + Math.random() * (max - min); const sleep = seconds => new Promise(r => setTimeout(r, seconds * 1000)); const human_delay = (min_ms = 80, max_ms = 300) => sleep(random_range(min_ms, max_ms) / 1000); async function check_limits(response) { for (const entry of (response.headers.ratelimit || '').split(',')) { const left = entry.match(/;r=(\d+)/); const reset = entry.match(/;t=(\d+)/); if (left && reset && +left[1] < 5) { const wait = +reset[1] + 1; console.log(`⏳ Octo rate limit reached, waiting ${wait}s`); await sleep(wait); } } } async function octo_call(action, body) { const res = await axios.post(`${config.octo_local_api_base_url}/${action}`, body, { headers: { 'Content-Type': 'application/json' } }); await check_limits(res); return res.data; } // Resolves all selectors in a single round trip instead of one call per // selector: on a 0.5s poll that adds up over a long run. async function wait_for_any(page, selectors, timeout = 30000) { const list = Array.isArray(selectors) ? selectors : [selectors]; const started = Date.now(); while (Date.now() - started < timeout) { const hit = await page.evaluate(sels => sels.find(s => { const el = document.querySelector(s); if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }), list); if (hit) return await page.$(hit); await sleep(0.5); } throw new Error(`None of the selectors matched: ${list.join(' | ')}`); } async function type_into_box(page, handle, text) { await handle.click(); await human_delay(200, 500); await page.keyboard.down(MOD_KEY); await page.keyboard.press('KeyA'); await page.keyboard.up(MOD_KEY); await page.keyboard.press('Backspace'); await human_delay(150, 400); for (const chunk of text.split(/(?<=\s)/)) { await page.keyboard.type(chunk, { delay: random_range(15, 55) }); if (Math.random() < 0.15) await human_delay(200, 700); } } async function dismiss_dialogs(page) { for (const selector of SELECTORS.dialogs) { const handle = await page.$(selector); if (!handle) continue; await handle.click().catch(() => { }); await human_delay(500, 1200); } } // Closes tabs we do not need. Studio opens some on its own, and Octo // persists the session, so leftovers reappear on the next launch. // Without `keep` the last surviving tab is parked on about:blank. async function prune_tabs(browser, keep = null) { try { const pages = await browser.pages(); const survivor = keep || pages[0]; for (const p of pages) { if (p !== survivor) await p.close().catch(() => { }); } if (!keep && survivor) { await survivor.goto('about:blank', { timeout: 15000 }).catch(() => { }); } } catch (e) { console.log(` ⚠️ Tab cleanup failed: ${e.message}`); } } async function wait_for_processing(page, timeout_min) { const deadline = Date.now() + timeout_min * 60 * 1000; let last_report = 0; while (Date.now() < deadline) { const percent = await page.evaluate(sel => { const el = document.querySelector(sel); const m = el && el.textContent.match(/(\d+)\s*%/); return m ? Number(m[1]) : null; }, SELECTORS.progress); // The percentage disappearing from the label means the upload is // done and Studio switched to its processing or checks message. if (percent === null || percent >= 100) return true; if (percent - last_report >= 10) { console.log(` ⬆️ Uploaded ${percent}%`); last_report = percent; } await sleep(5); } return false; } async function fill_metadata(page, video) { const title_box = await wait_for_any(page, SELECTORS.title, 90000); await human_delay(1500, 3000); await type_into_box(page, title_box, video.title); console.log(` ✏️ Title: ${video.title}`); if (video.description?.trim()) { const box = await wait_for_any(page, SELECTORS.description); await type_into_box(page, box, video.description); console.log(' 📝 Description filled in'); } if (video.tags?.length) { const toggle = await page.$(SELECTORS.tags_toggle); if (toggle) { await toggle.click(); await human_delay(1000, 2000); const input = await page.$(SELECTORS.tags_input); if (input) { await input.click(); for (const tag of video.tags) { await page.keyboard.type(tag, { delay: random_range(20, 60) }); await page.keyboard.press('Comma'); await human_delay(300, 800); } console.log(` 🏷 Tags: ${video.tags.join(', ')}`); } } } const not_for_kids = await page.$(SELECTORS.not_for_kids); if (not_for_kids) { await not_for_kids.click(); await human_delay(600, 1400); } } async function upload_one(page, video) { console.log(`\n🎬 ${path.basename(video.file)}`); await page.goto('https://www.youtube.com/upload', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(2000, 4000); await dismiss_dialogs(page); const file_input = await page.waitForSelector(SELECTORS.file, { timeout: 45000 }); await file_input.uploadFile(video.file); console.log(' 📤 File handed to the input, waiting for the metadata dialog'); await fill_metadata(page, video); // Three wizard steps: details, video elements, checks. for (let step = 0; step < 3; step++) { const next = await wait_for_any(page, SELECTORS.next); await next.click(); await human_delay(1500, 3000); } const visibility = video.visibility || 'PRIVATE'; const radio = await wait_for_any(page, `tp-yt-paper-radio-button[name="${visibility}"]`); await radio.click(); console.log(` 👁 Visibility: ${visibility}`); await human_delay(1000, 2000); if (!await wait_for_processing(page, config.upload_timeout_min)) { console.log(' ⚠️ Processing timed out, the video stays a draft'); return { file: video.file, success: false, reason: 'processing_timeout' }; } const done = await wait_for_any(page, SELECTORS.done, 60000); await done.click(); await human_delay(3000, 6000); await dismiss_dialogs(page); await prune_tabs(page.browser(), page); console.log(' ✅ Published'); return { file: video.file, success: true, visibility }; } async function process_profile(entry, index, total) { const line = '='.repeat(70); console.log(`\n${line}\n📋 Profile ${index + 1}/${total} — ${entry.channel} (${entry.uuid})`); console.log(` Videos queued: ${entry.videos.length}\n${line}`); const stats = { channel: entry.channel, uuid: entry.uuid, status: 'ok', uploads: [] }; await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(3); let start_data; try { start_data = await octo_call('start', { uuid: entry.uuid, headless: config.headless_mode, debug_port: true, timeout: 120 }); } catch (e) { const body = e.response?.data ? JSON.stringify(e.response.data) : e.message; console.error(`❌ Profile failed to start: ${body}`); return { ...stats, status: 'start_failed' }; } if (!start_data?.ws_endpoint) { console.error('❌ Octo returned no ws_endpoint'); await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); return { ...stats, status: 'no_ws' }; } let browser; try { browser = await puppeteer.connect({ browserWSEndpoint: start_data.ws_endpoint, defaultViewport: null, protocolTimeout: 3600000 }); // The profile starts with a tab already open. Reuse it, otherwise // a second, empty one shows up next to it. const pages = await browser.pages(); const page = pages[0] || await browser.newPage(); await page.setViewport({ width: 1440, height: 900 }); await page.goto('https://studio.youtube.com/', { waitUntil: 'domcontentloaded', timeout: 60000 }); await human_delay(1500, 2500); if (page.url().includes('accounts.google.com')) { console.error('❌ Profile is not signed in to Google'); return { ...stats, status: 'not_logged_in' }; } const limit = Math.min(entry.videos.length, config.daily_limit_per_channel); for (let i = 0; i < limit; i++) { try { stats.uploads.push(await upload_one(page, entry.videos[i])); } catch (e) { console.error(`❌ Upload error: ${e.message}`); stats.uploads.push({ file: entry.videos[i].file, success: false, reason: e.message }); } if (i < limit - 1) { const pause = random_range(config.delay_between_uploads.min, config.delay_between_uploads.max); console.log(`⏰ Pause before the next video: ${Math.round(pause)}s`); await sleep(pause); } } if (entry.videos.length > limit) { console.log(`ℹ️ Left in the queue for tomorrow: ${entry.videos.length - limit}`); } } catch (e) { console.error(`❌ Profile processing error: ${e.message}`); stats.status = 'error'; stats.error = e.message; } finally { if (browser) { await prune_tabs(browser); browser.disconnect(); } await octo_call('stop', { uuid: entry.uuid }).catch(() => { }); await sleep(2); } return stats; } // Validate the queue before launching a browser. An empty title or a wrong // file path would otherwise pass silently: there is nothing to type, no // error is raised, and the video lands on YouTube named after its file. async function validate_queue(queue) { const VISIBILITY = ['PRIVATE', 'UNLISTED', 'PUBLIC']; if (!Array.isArray(queue) || !queue.length) return ['the queue is empty or is not an array']; const problems = []; for (const [i, entry] of queue.entries()) { const where = `entry ${i + 1} (${entry.channel || 'unnamed'})`; if (!entry.uuid) problems.push(`${where}: no profile uuid`); if (!Array.isArray(entry.videos) || !entry.videos.length) { problems.push(`${where}: no videos at all`); continue; } for (const [j, video] of entry.videos.entries()) { const at = `${where}, video ${j + 1}`; const readable = video.file && await fs.access(video.file).then(() => true, () => false); if (!video.file) problems.push(`${at}: no file path`); else if (!readable) problems.push(`${at}: file not found — ${video.file}`); if (!video.title?.trim()) problems.push(`${at}: empty title`); else if (video.title.length > 100) problems.push(`${at}: title over 100 chars (${video.title.length})`); if (video.description?.length > 5000) problems.push(`${at}: description over 5000 chars`); if (video.visibility && !VISIBILITY.includes(video.visibility)) { problems.push(`${at}: visibility "${video.visibility}", allowed: ${VISIBILITY.join(', ')}`); } } } return problems; } (async () => { console.log('🚀 Octo YouTube Bulk Uploader'); const queue = JSON.parse(await fs.readFile(path.join(__dirname, config.queue_file), 'utf8')); const problems = await validate_queue(queue); if (problems.length) { console.error(`\n❌ Queue validation failed (${problems.length}):`); for (const p of problems) console.error(` • ${p}`); console.error('\nFix the queue and run again. No browser was launched.'); process.exit(1); } const total_videos = queue.reduce((sum, e) => sum + e.videos.length, 0); console.log(` ✅ Queue validated — ${queue.length} channels, ${total_videos} videos`); console.log(` Per-channel limit for this run: ${config.daily_limit_per_channel}\n`); const results_dir = path.join(__dirname, config.results_dir); await fs.mkdir(results_dir, { recursive: true }); const all_stats = []; for (let i = 0; i < queue.length; i++) { all_stats.push(await process_profile(queue[i], i, queue.length)); if (i < queue.length - 1) { const pause = random_range(config.delay_between_profiles.min, config.delay_between_profiles.max); console.log(`\n⏰ Pause before the next profile: ${Math.round(pause)}s`); await sleep(pause); } } console.log(`\n${'='.repeat(70)}\n📊 SUMMARY\n${'='.repeat(70)}`); for (const s of all_stats) { const ok = s.uploads.filter(u => u.success).length; console.log(`\n${s.channel} → ${s.status}, succeeded ${ok}/${s.uploads.length}`); for (const u of s.uploads) { console.log(u.success ? ` ✅ ${path.basename(u.file)} → ${u.visibility}` : ` ❌ ${path.basename(u.file)}: ${u.reason}`); } } const report_path = path.join(results_dir, `report_${Date.now()}.json`); await fs.writeFile(report_path, JSON.stringify(all_stats, null, 2), 'utf8'); console.log(`\n📄 Report: ${report_path}\n🎉 Done.`); })();
Danh sách kiểm tra trước khi chạy toàn bộ hệ thống
Các kênh đã được chuẩn bị đúng cách. Một kênh mới nhận được hai mươi video trong ngày đầu tiên hoạt động sẽ trông rất đáng ngờ, bất kể cách tải lên như thế nào. Hãy bắt đầu với hai hoặc ba lượt tải lên mỗi ngày và phát triển kênh dần dần.
Các proxy khác nhau và không phải loại từ trung tâm dữ liệu (datacenter). Một địa chỉ IP cho hai mươi kênh sẽ làm mất đi tác dụng của việc cô lập hồ sơ.
Siêu dữ liệu không sử dụng chung một khuôn mẫu (template). Các mô tả giống hệt nhau với các tập hợp liên kết giống hệt nhau trên toàn bộ mạng lưới là một trong những dấu hiệu rõ ràng nhất cho thấy các kênh có liên kết với nhau. Hãy tạo ra các biến thể.
Các bộ chọn đã được thử nghiệm. Giao diện Studio thay đổi theo thời gian, vì vậy trước khi thực hiện một lượt chạy lớn, hãy chạy thử script trên một hồ sơ với tùy chọn
headless_mode: falsevà kiểm tra trực quan xem nó có thể bị lỗi ở đâu.Tải lên từng video một cho mỗi hồ sơ. Việc tải lên song song nhiều video từ cùng một hồ sơ bị giới hạn bởi băng thông của kênh và làm tăng khả năng xảy ra lỗi quá thời gian chờ (timeout). Hãy chạy song song các hồ sơ, chứ không phải các video trong cùng một hồ sơ.
Nội dung không bị trùng lặp. Việc tải cùng một tệp lên mười kênh khác nhau có thể vi phạm chính sách nội dung trùng lặp của YouTube. Việc bản địa hóa, chỉnh sửa và lồng tiếng cần phải khác nhau.
Phương pháp tiếp cận kết hợp: tải lên bằng trình duyệt, quản lý bằng API
Phương pháp này trước đây được coi là hợp lý vì lý do chi phí: tải lên qua API tốn 1.600 đơn vị, trong khi mọi hoạt động khác chỉ tốn từ 1 đến 50 đơn vị. Sau khi đợt giảm giá hồi tháng 12 được áp dụng, lập luận về chi phí không còn nữa, nhưng bản thân phương pháp tiếp cận này vẫn hữu ích, chỉ là vì một lý do khác.
Các lượt tải lên qua API từ một dự án chưa được xác minh sẽ khiến video bị khóa ở chế độ riêng tư, trong khi nhóm videos.insert bị giới hạn ở mức tối đa một trăm lệnh gọi mỗi ngày cho mỗi dự án và chỉ có thể mở rộng thông qua quy trình kiểm duyệt. Tất cả các hoạt động khác không phải chịu bất kỳ hạn chế nào trong hai hạn chế này: chúng được khấu trừ từ nhóm 10.000 đơn vị chung, trong đó việc chỉnh sửa siêu dữ liệu tốn 50 đơn vị và đọc siêu dữ liệu tốn một đơn vị.
Điều này dẫn đến sự phân chia trách nhiệm rõ rệt. Các tệp video được tải lên thông qua tự động hóa trình duyệt vì cách này tránh được quy tắc ứng dụng khách chưa xác minh và không tiêu tốn hạn ngạch tải lên vốn rất hạn hẹp. Sau đó, API sẽ tiếp quản: nó chỉnh sửa tiêu đề và mô tả qua videos.update, đặt hình thu nhỏ qua thumbnails.set, sắp xếp video vào danh sách phát và thu thập số liệu thống kê. Không cần kiểm duyệt cho các hoạt động này và không có rủi ro video bị khóa riêng tư vì bản thân việc tải lên đã được thực hiện bởi trình duyệt.
Trình duyệt làm những việc có rủi ro cao nếu thực hiện qua API; API làm những việc chậm chạp và dễ lỗi nếu thực hiện qua trình duyệt. Việc chỉnh sửa hàng loạt mô tả cho hai trăm video qua API chỉ mất một phút, trong khi thực hiện việc đó thông qua tự động hóa Studio trên trình duyệt sẽ mất nửa ngày.
Kết luận
Việc tải lên video YouTube hàng loạt gặp phải nhiều trở ngại chứ không chỉ là giới hạn hạn ngạch. Kể từ cuối năm 2025, hạn ngạch Data API về cơ bản đã không còn là vấn đề lớn: một trăm lượt tải lên mỗi ngày cho mỗi dự án đã đáp ứng được hầu hết các trường hợp sử dụng và giới hạn này có thể được mở rộng thông qua kiểm duyệt. Vấn đề video bị khóa riêng tư do tải lên từ các dự án chưa được xác minh có thể được giải quyết thông qua chính quy trình kiểm duyệt đó hoặc bằng cách tải video lên trực tiếp trong trình duyệt. Giới hạn tải lên hàng ngày của kênh thì hoàn toàn không thể bỏ qua, vì vậy khối lượng công việc cần được phân bổ trên nhiều kênh khác nhau.
Mã script trong bài viết này xử lý trường hợp cuối cùng: nó biến một mạng lưới các kênh thành một quy trình quản lý tập trung duy nhất, trong đó hàng đợi công việc nằm trong tệp JSON và mỗi kênh hoạt động từ một hồ sơ cô lập riêng biệt với proxy riêng. Nó có thể được mở rộng theo nhiều cách rõ ràng: gán hình thu nhỏ, thêm phụ đề, lên lịch xuất bản thay vì xuất bản ngay lập tức, hoặc lưu trạng thái hàng đợi liên tục để lượt chạy có thể tiếp tục từ điểm đã dừng của ngày hôm trước.
Khả năng phục hồi và độ bền bỉ của một hệ thống như vậy không được quyết định bởi mã script (vốn là phần đơn giản nhất). Hệ thống sẽ hoạt động tốt miễn là các kênh xuất hiện độc lập với nhau: vân tay khác nhau, địa chỉ IP khác nhau, nội dung khác nhau và tốc độ xuất bản hợp lý.
Cập nhật với các tin tức Octo Browser mới nhất
Khi nhấp vào nút này, bạn sẽ đồng ý với Chính sách Quyền riêng tư của chúng tôi.
Cập nhật với các tin tức Octo Browser mới nhất
Khi nhấp vào nút này, bạn sẽ đồng ý với Chính sách Quyền riêng tư của chúng tôi.
Cập nhật với các tin tức Octo Browser mới nhất
Khi nhấp vào nút này, bạn sẽ đồng ý với Chính sách Quyền riêng tư của chúng tôi.

Tham gia Octo Browser ngay
Hoặc liên hệ với Dịch vụ khách hàng bất kì lúc nào nếu bạn có bất cứ thắc mắc nào.

Tham gia Octo Browser ngay
Hoặc liên hệ với Dịch vụ khách hàng bất kì lúc nào nếu bạn có bất cứ thắc mắc nào.
Tham gia Octo Browser ngay
Hoặc liên hệ với Dịch vụ khách hàng bất kì lúc nào nếu bạn có bất cứ thắc mắc nào.