publish.js
1 /** 2 * Douyin publish — 8-phase pipeline for scheduling video posts. 3 * 4 * Phases: 5 * 1. STS2 credentials 6 * 2. Apply TOS upload URL 7 * 3. TOS multipart upload 8 * 4. Cover upload (optional, via ImageX) 9 * 5. Enable video 10 * 6. Poll transcode 11 * 7. Content safety check 12 * 8. create_v2 publish 13 */ 14 import * as fs from 'node:fs'; 15 import * as path from 'node:path'; 16 import { cli, Strategy } from '@jackwener/opencli/registry'; 17 import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; 18 import { getSts2Credentials } from './_shared/sts2.js'; 19 import { tosUpload } from './_shared/tos-upload.js'; 20 import { imagexUpload } from './_shared/imagex-upload.js'; 21 import { pollTranscode } from './_shared/transcode.js'; 22 import { browserFetch } from './_shared/browser-fetch.js'; 23 import { generateCreationId } from './_shared/creation-id.js'; 24 import { validateTiming, toUnixSeconds } from './_shared/timing.js'; 25 import { parseTextExtra, extractHashtagNames } from './_shared/text-extra.js'; 26 const VISIBILITY_MAP = { 27 public: 0, 28 friends: 1, 29 private: 2, 30 }; 31 const IMAGEX_BASE = 'https://imagex.bytedanceapi.com'; 32 const IMAGEX_SERVICE_ID = '1147'; 33 const DEVICE_PARAMS = 'aid=1128&cookie_enabled=true&screen_width=1512&screen_height=982&browser_language=zh-CN&browser_platform=MacIntel&browser_name=Mozilla&browser_online=true&timezone_name=Asia%2FTokyo&support_h265=1'; 34 const DEFAULT_COVER_TOOLS_INFO = JSON.stringify({ 35 video_cover_source: 2, 36 cover_timestamp: 0, 37 recommend_timestamp: 0, 38 is_cover_edit: 0, 39 is_cover_template: 0, 40 cover_template_id: '', 41 is_text_template: 0, 42 text_template_id: '', 43 text_template_content: '', 44 is_text: 0, 45 text_num: 0, 46 text_content: '', 47 is_use_sticker: 0, 48 sticker_id: '', 49 is_use_filter: 0, 50 filter_id: '', 51 is_cover_modify: 0, 52 to_status: 0, 53 cover_type: 0, 54 initial_cover_uri: '', 55 cut_coordinate: '', 56 }); 57 cli({ 58 site: 'douyin', 59 name: 'publish', 60 description: '定时发布视频到抖音(必须设置 2h ~ 14天后的发布时间)', 61 domain: 'creator.douyin.com', 62 strategy: Strategy.COOKIE, 63 args: [ 64 { name: 'video', required: true, positional: true, help: '视频文件路径' }, 65 { name: 'title', required: true, help: '视频标题(≤30字)' }, 66 { name: 'schedule', required: true, help: '定时发布时间(ISO8601 或 Unix 秒,2h ~ 14天后)' }, 67 { name: 'caption', default: '', help: '正文内容(≤1000字,支持 #话题)' }, 68 { name: 'cover', default: '', help: '封面图片路径(不提供时使用视频截帧)' }, 69 { name: 'visibility', default: 'public', choices: ['public', 'friends', 'private'] }, 70 { name: 'allow_download', type: 'bool', default: false, help: '允许下载' }, 71 { name: 'collection', default: '', help: '合集 ID' }, 72 { name: 'activity', default: '', help: '活动 ID' }, 73 { name: 'poi_id', default: '', help: '地理位置 ID' }, 74 { name: 'poi_name', default: '', help: '地理位置名称' }, 75 { name: 'hotspot', default: '', help: '关联热点词' }, 76 { name: 'no_safety_check', type: 'bool', default: false, help: '跳过内容安全检测' }, 77 { name: 'sync_toutiao', type: 'bool', default: false, help: '同步发布到头条' }, 78 ], 79 columns: ['status', 'aweme_id', 'url', 'publish_time'], 80 func: async (page, kwargs) => { 81 // ── Fail-fast validation ──────────────────────────────────────────── 82 const videoPath = path.resolve(kwargs.video); 83 if (!fs.existsSync(videoPath)) { 84 throw new ArgumentError(`视频文件不存在: ${videoPath}`); 85 } 86 const ext = path.extname(videoPath).toLowerCase(); 87 if (!['.mp4', '.mov', '.avi', '.webm'].includes(ext)) { 88 throw new ArgumentError(`不支持的视频格式: ${ext}(支持 mp4/mov/avi/webm)`); 89 } 90 const fileSize = fs.statSync(videoPath).size; 91 const title = kwargs.title; 92 if (title.length > 30) { 93 throw new ArgumentError('标题不能超过 30 字'); 94 } 95 const caption = kwargs.caption || ''; 96 if (caption.length > 1000) { 97 throw new ArgumentError('正文不能超过 1000 字'); 98 } 99 const timingTs = toUnixSeconds(kwargs.schedule); 100 validateTiming(timingTs); 101 const visibilityType = VISIBILITY_MAP[kwargs.visibility] ?? 0; 102 const coverPath = kwargs.cover; 103 if (coverPath) { 104 if (!fs.existsSync(path.resolve(coverPath))) { 105 throw new ArgumentError(`封面文件不存在: ${path.resolve(coverPath)}`); 106 } 107 } 108 // ── Phase 1: STS2 credentials ─────────────────────────────────────── 109 const credentials = await getSts2Credentials(page); 110 // ── Phase 2: Apply TOS upload URL ─────────────────────────────────── 111 const vodUrl = `https://vod.bytedanceapi.com/?Action=ApplyVideoUpload&ServiceId=1128&Version=2021-01-01&FileType=video&FileSize=${fileSize}`; 112 const vodJs = `fetch(${JSON.stringify(vodUrl)}, { credentials: 'include' }).then(r => r.json())`; 113 const vodRes = (await page.evaluate(vodJs)); 114 const { VideoId: videoId, UploadHosts, StoreInfos } = vodRes.Result.UploadAddress; 115 const tosUrl = `https://${UploadHosts[0]}/${StoreInfos[0].StoreUri}`; 116 const tosUploadInfo = { 117 tos_upload_url: tosUrl, 118 auth: StoreInfos[0].Auth, 119 video_id: videoId, 120 }; 121 // ── Phase 3: TOS upload ───────────────────────────────────────────── 122 await tosUpload({ 123 filePath: videoPath, 124 uploadInfo: tosUploadInfo, 125 credentials, 126 onProgress: (uploaded, total) => { 127 const pct = Math.round((uploaded / total) * 100); 128 process.stderr.write(`\r 上传进度: ${pct}%`); 129 }, 130 }); 131 process.stderr.write('\n'); 132 // ── Phase 4: Cover upload (optional) ──────────────────────────────── 133 let coverUri = ''; 134 let coverWidth = 720; 135 let coverHeight = 1280; 136 if (kwargs.cover) { 137 const resolvedCoverPath = path.resolve(kwargs.cover); 138 // 4A: Apply ImageX upload 139 const applyUrl = `${IMAGEX_BASE}/?Action=ApplyImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01&UploadNum=1`; 140 const applyJs = `fetch(${JSON.stringify(applyUrl)}, { credentials: 'include' }).then(r => r.json())`; 141 const applyRes = (await page.evaluate(applyJs)); 142 const { StoreInfos: imgStoreInfos } = applyRes.Result.UploadAddress; 143 const imgUploadUrl = `https://${imgStoreInfos[0].UploadHost}/${imgStoreInfos[0].StoreUri}`; 144 // 4B: Upload image 145 const coverStoreUri = await imagexUpload(resolvedCoverPath, { 146 upload_url: imgUploadUrl, 147 store_uri: imgStoreInfos[0].StoreUri, 148 }); 149 // 4C: Commit ImageX upload 150 const commitUrl = `${IMAGEX_BASE}/?Action=CommitImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01`; 151 const commitBody = JSON.stringify({ SuccessObjKeys: [coverStoreUri] }); 152 const commitJs = ` 153 fetch(${JSON.stringify(commitUrl)}, { 154 method: 'POST', 155 credentials: 'include', 156 headers: { 'Content-Type': 'application/json' }, 157 body: ${JSON.stringify(commitBody)} 158 }).then(r => r.json()) 159 `; 160 await page.evaluate(commitJs); 161 coverUri = coverStoreUri; 162 } 163 // ── Phase 5: Enable video ─────────────────────────────────────────── 164 const enableUrl = `https://creator.douyin.com/web/api/media/video/enable/?video_id=${videoId}&aid=1128`; 165 await browserFetch(page, 'GET', enableUrl); 166 // ── Phase 6: Poll transcode ───────────────────────────────────────── 167 const transResult = await pollTranscode(page, videoId); 168 coverWidth = transResult.width; 169 coverHeight = transResult.height; 170 if (!coverUri) { 171 coverUri = transResult.poster_uri; 172 } 173 // ── Phase 7: Content safety check ─────────────────────────────────── 174 if (!kwargs.no_safety_check) { 175 const safetyUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/pre_check'; 176 const safetyBody = { 177 video_id: videoId, 178 title, 179 desc: caption, 180 }; 181 await browserFetch(page, 'POST', safetyUrl, { body: safetyBody }); 182 const pollUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/poll'; 183 const deadline = Date.now() + 30_000; 184 let safetyPassed = false; 185 while (Date.now() < deadline) { 186 const pollRes = (await browserFetch(page, 'POST', pollUrl, { 187 body: safetyBody, 188 })); 189 if (pollRes.status === 0) { 190 safetyPassed = true; 191 break; 192 } 193 if (pollRes.status === 1) { 194 throw new CommandExecutionError('内容安全检测不通过,请修改后重试', '使用 --no_safety_check 跳过'); 195 } 196 await new Promise((r) => setTimeout(r, 2000)); 197 } 198 if (!safetyPassed) { 199 throw new CommandExecutionError('内容安全检测超时(30s),请稍后重试', '使用 --no_safety_check 跳过'); 200 } 201 } 202 // ── Phase 8: create_v2 publish ────────────────────────────────────── 203 const hashtagNames = extractHashtagNames(caption); 204 const hashtags = []; 205 let searchFrom = 0; 206 for (const name of hashtagNames) { 207 const idx = caption.indexOf(`#${name}`, searchFrom); 208 if (idx === -1) 209 continue; 210 hashtags.push({ name, id: 0, start: idx, end: idx + name.length + 1 }); 211 searchFrom = idx + name.length + 1; 212 } 213 const textExtraArr = parseTextExtra(caption, hashtags); 214 const publishBody = { 215 item: { 216 common: { 217 text: caption, 218 caption: '', 219 item_title: title, 220 activity: JSON.stringify(kwargs.activity ? [kwargs.activity] : []), 221 text_extra: JSON.stringify(textExtraArr), 222 challenges: '[]', 223 mentions: '[]', 224 hashtag_source: '', 225 hot_sentence: kwargs.hotspot || '', 226 interaction_stickers: '[]', 227 visibility_type: visibilityType, 228 download: kwargs.allow_download ? 1 : 0, 229 timing: timingTs, 230 creation_id: generateCreationId(), 231 media_type: 4, 232 video_id: videoId, 233 music_source: 0, 234 music_id: null, 235 ...(kwargs.poi_id 236 ? { poi_id: kwargs.poi_id, poi_name: kwargs.poi_name } 237 : {}), 238 }, 239 cover: { 240 poster: coverUri, 241 custom_cover_image_height: coverHeight, 242 custom_cover_image_width: coverWidth, 243 poster_delay: 0, 244 cover_tools_info: DEFAULT_COVER_TOOLS_INFO, 245 cover_tools_extend_info: '{}', 246 }, 247 mix: kwargs.collection 248 ? { mix_id: kwargs.collection, mix_order: 0 } 249 : {}, 250 chapter: { 251 chapter: JSON.stringify({ 252 chapter_abstract: '', 253 chapter_details: [], 254 chapter_type: 0, 255 }), 256 }, 257 anchor: {}, 258 sync: { 259 should_sync: false, 260 sync_to_toutiao: kwargs.sync_toutiao ? 1 : 0, 261 }, 262 open_platform: {}, 263 assistant: { is_preview: 0, is_post_assistant: 1 }, 264 declare: { user_declare_info: '{}' }, 265 }, 266 }; 267 const publishUrl = `https://creator.douyin.com/web/api/media/aweme/create_v2/?read_aid=2906&${DEVICE_PARAMS}`; 268 const publishRes = (await browserFetch(page, 'POST', publishUrl, { 269 body: publishBody, 270 })); 271 const awemeId = publishRes.aweme_id; 272 if (!awemeId) { 273 throw new CommandExecutionError(`发布成功但未返回 aweme_id: ${JSON.stringify(publishRes)}`); 274 } 275 const url = `https://www.douyin.com/video/${awemeId}`; 276 const publishTimeStr = new Date(timingTs * 1000).toLocaleString('zh-CN', { 277 timeZone: 'Asia/Tokyo', 278 }); 279 return [ 280 { 281 status: '✅ 定时发布成功!', 282 aweme_id: awemeId, 283 url, 284 publish_time: publishTimeStr, 285 }, 286 ]; 287 }, 288 });