draft.js
1 /** 2 * Douyin draft — upload through the official creator page and save as draft. 3 * 4 * The previous API pipeline relied on an old pre-upload endpoint that no longer 5 * matches creator center's live upload flow. This command now drives the 6 * official upload page directly so it stays aligned with the site. 7 */ 8 import * as fs from 'node:fs'; 9 import * as path from 'node:path'; 10 import { cli, Strategy } from '@jackwener/opencli/registry'; 11 import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; 12 const VISIBILITY_LABELS = { 13 public: '公开', 14 friends: '好友可见', 15 private: '仅自己可见', 16 }; 17 const DRAFT_UPLOAD_URL = 'https://creator.douyin.com/creator-micro/content/upload'; 18 const COMPOSER_WAIT_ATTEMPTS = 120; 19 const COVER_INPUT_WAIT_ATTEMPTS = 20; 20 const COVER_READY_WAIT_ATTEMPTS = 20; 21 /** 22 * Best-effort dismissal for coach marks and upload tips that can block clicks. 23 */ 24 async function dismissKnownModals(page) { 25 await page.evaluate(`() => { 26 const targets = ['我知道了', '知道了', '关闭']; 27 for (const text of targets) { 28 const btn = Array.from(document.querySelectorAll('button,[role="button"]')) 29 .find((el) => (el.textContent || '').trim() === text); 30 if (btn instanceof HTMLElement) btn.click(); 31 } 32 }`); 33 } 34 /** 35 * Wait until Douyin finishes uploading and lands on the post-video composer. 36 */ 37 async function waitForDraftComposer(page) { 38 let lastState = { 39 href: '', 40 ready: false, 41 bodyText: '', 42 }; 43 for (let attempt = 0; attempt < COMPOSER_WAIT_ATTEMPTS; attempt += 1) { 44 lastState = (await page.evaluate(`() => ({ 45 href: location.href, 46 ready: !!Array.from(document.querySelectorAll('input')).find( 47 (el) => (el.placeholder || '').includes('填写作品标题') 48 ) && !!Array.from(document.querySelectorAll('button')).find( 49 (el) => (el.textContent || '').includes('暂存离开') 50 ), 51 bodyText: document.body?.innerText || '' 52 })`)); 53 if (lastState.ready) 54 return; 55 await page.wait({ time: 0.5 }); 56 } 57 throw new CommandExecutionError('等待抖音草稿编辑页超时', `当前页面: ${lastState.href || 'unknown'}`); 58 } 59 /** 60 * Fill title, caption and visibility controls on the live composer page. 61 */ 62 async function fillDraftComposer(page, options) { 63 const titleOk = (await page.evaluate(`() => { 64 const titleInput = Array.from(document.querySelectorAll('input')).find( 65 (el) => (el.placeholder || '').includes('填写作品标题') 66 ); 67 if (!(titleInput instanceof HTMLInputElement)) return false; 68 const propKey = Object.keys(titleInput).find((key) => key.startsWith('__reactProps$')); 69 const props = propKey ? titleInput[propKey] : null; 70 if (props?.onChange) { 71 props.onChange({ 72 target: { value: ${JSON.stringify(options.title)} }, 73 currentTarget: { value: ${JSON.stringify(options.title)} }, 74 }); 75 } else { 76 titleInput.focus(); 77 titleInput.value = ${JSON.stringify(options.title)}; 78 titleInput.dispatchEvent(new Event('input', { bubbles: true })); 79 titleInput.dispatchEvent(new Event('change', { bubbles: true })); 80 } 81 if (props?.onBlur) { 82 props.onBlur({ 83 target: titleInput, 84 currentTarget: titleInput, 85 relatedTarget: null, 86 }); 87 } else { 88 titleInput.dispatchEvent(new Event('blur', { bubbles: true })); 89 } 90 return true; 91 }`)); 92 if (!titleOk) { 93 throw new CommandExecutionError('填写抖音草稿表单失败: title-input-missing'); 94 } 95 if (options.caption) { 96 const captionOk = (await page.evaluate(`() => { 97 const editor = document.querySelector('[contenteditable="true"]'); 98 if (!(editor instanceof HTMLElement)) return false; 99 editor.focus(); 100 editor.textContent = ''; 101 document.execCommand('selectAll', false); 102 document.execCommand('insertText', false, ${JSON.stringify(options.caption)}); 103 editor.dispatchEvent(new Event('input', { bubbles: true })); 104 return true; 105 }`)); 106 if (!captionOk) { 107 throw new CommandExecutionError('填写抖音草稿表单失败: caption-editor-missing'); 108 } 109 } 110 const visibilityOk = (await page.evaluate(`() => { 111 const visibility = Array.from(document.querySelectorAll('label')).find( 112 (el) => (el.textContent || '').includes(${JSON.stringify(options.visibilityLabel)}) 113 ); 114 if (!(visibility instanceof HTMLElement)) return false; 115 visibility.click(); 116 return true; 117 }`)); 118 if (!visibilityOk) { 119 throw new CommandExecutionError('填写抖音草稿表单失败: visibility-missing'); 120 } 121 } 122 /** 123 * Switch the composer into custom-cover mode and expose the cover input with a 124 * stable selector for CDP file injection. 125 */ 126 async function prepareCustomCoverInput(page) { 127 let lastReason = 'cover-input-missing'; 128 const baselineCount = (await page.evaluate(`() => Array.from(document.querySelectorAll('input[type="file"]')).length`)); 129 for (let attempt = 0; attempt < COVER_INPUT_WAIT_ATTEMPTS; attempt += 1) { 130 const result = (await page.evaluate(`() => { 131 const coverLabel = Array.from(document.querySelectorAll('label')).find( 132 (el) => (el.textContent || '').includes('上传新封面') 133 ); 134 if (coverLabel instanceof HTMLElement) { 135 coverLabel.click(); 136 } 137 138 const inputs = Array.from(document.querySelectorAll('input[type="file"]')); 139 const target = inputs 140 .slice(${JSON.stringify(baselineCount)}) 141 .find((el) => el instanceof HTMLInputElement && !el.disabled); 142 if (!(target instanceof HTMLInputElement)) { 143 return { ok: false, reason: 'cover-input-pending' }; 144 } 145 146 document 147 .querySelectorAll('[data-opencli-cover-input="1"]') 148 .forEach((el) => el.removeAttribute('data-opencli-cover-input')); 149 target.setAttribute('data-opencli-cover-input', '1'); 150 return { ok: true, selector: '[data-opencli-cover-input="1"]' }; 151 }`)); 152 if (result?.ok && result.selector) { 153 return result.selector; 154 } 155 lastReason = result?.reason || lastReason; 156 await page.wait({ time: 0.5 }); 157 } 158 throw new CommandExecutionError(`准备抖音自定义封面输入框失败: ${lastReason}`); 159 } 160 /** 161 * Read the local quick-check panel text that reflects cover validation state. 162 */ 163 export function buildCoverCheckPanelTextJs() { 164 return `() => { 165 const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); 166 const stateTexts = ['检测', '检测中', '封面检测中', '重新检测', '横/竖双封面缺失']; 167 const marker = Array.from(document.querySelectorAll('div,span,p,button')).find( 168 (el) => normalize(el.textContent) === '快速检测' 169 ); 170 let root = marker?.parentElement || null; 171 while (root && root !== document.body) { 172 const descendants = Array.from(root.querySelectorAll('div,span,p,button')) 173 .map((el) => normalize(el.textContent)); 174 const hasMarkerText = descendants.includes('快速检测'); 175 const hasStateText = descendants.some((text) => stateTexts.includes(text)); 176 if (hasMarkerText && hasStateText) { 177 return normalize(root.textContent).slice(0, 400); 178 } 179 root = root.parentElement; 180 } 181 return ''; 182 }`; 183 } 184 async function getCoverCheckPanelText(page) { 185 return (await page.evaluate(buildCoverCheckPanelTextJs())) || ''; 186 } 187 /** 188 * Wait for Douyin's cover-detection pipeline to expose a post-upload signal. 189 * In the live creator page, custom cover upload first shows `封面检测中`, then 190 * lands on a ready state such as `重新检测` or the warning copy for missing 191 * horizontal/vertical covers. 192 */ 193 async function waitForCoverReady(page) { 194 let lastPanelText = ''; 195 let sawBusy = false; 196 for (let attempt = 0; attempt < COVER_READY_WAIT_ATTEMPTS; attempt += 1) { 197 const panelText = await getCoverCheckPanelText(page); 198 const busy = panelText.includes('检测中'); 199 const ready = (panelText.includes('重新检测') 200 || panelText.includes('横/竖双封面缺失')); 201 if (busy) { 202 sawBusy = true; 203 } 204 if (sawBusy && ready && !busy) { 205 return; 206 } 207 lastPanelText = panelText; 208 await page.wait({ time: 0.5 }); 209 } 210 throw new CommandExecutionError('等待抖音封面处理完成超时', lastPanelText || 'unknown'); 211 } 212 /** 213 * Click the draft button on the composer page and extract the current creation id. 214 */ 215 async function clickSaveDraft(page) { 216 const result = (await page.evaluate(`() => { 217 const extractCreationId = () => { 218 const titleInput = Array.from(document.querySelectorAll('input')).find( 219 (el) => (el.placeholder || '').includes('填写作品标题') 220 ); 221 if (!(titleInput instanceof HTMLInputElement)) return ''; 222 223 const fiberKey = Object.keys(titleInput).find((key) => key.startsWith('__reactFiber$')); 224 let fiber = fiberKey ? titleInput[fiberKey] : null; 225 while (fiber) { 226 const props = fiber.memoizedProps; 227 if (typeof props?.creation_id === 'string' && props.creation_id) { 228 return props.creation_id; 229 } 230 fiber = fiber.return; 231 } 232 return ''; 233 }; 234 235 const btn = Array.from(document.querySelectorAll('button')).find( 236 (el) => (el.textContent || '').includes('暂存离开') 237 ); 238 if (!(btn instanceof HTMLButtonElement)) { 239 return { ok: false, reason: 'draft-button-missing' }; 240 } 241 const creationId = extractCreationId(); 242 const propKey = Object.keys(btn).find((key) => key.startsWith('__reactProps$')); 243 const props = propKey ? btn[propKey] : null; 244 if (props?.onClick) { 245 props.onClick({ 246 preventDefault() {}, 247 stopPropagation() {}, 248 nativeEvent: null, 249 target: btn, 250 currentTarget: btn, 251 }); 252 } else { 253 btn.click(); 254 } 255 return { 256 ok: true, 257 text: (btn.textContent || '').trim(), 258 creationId, 259 }; 260 }`)); 261 if (!result?.ok) { 262 throw new CommandExecutionError(`点击草稿按钮失败: ${result?.reason || 'unknown'}`); 263 } 264 if (!result.creationId) { 265 throw new CommandExecutionError('点击草稿按钮失败: creation-id-missing'); 266 } 267 return { 268 text: result.text || '暂存离开', 269 creationId: result.creationId, 270 }; 271 } 272 /** 273 * Wait until creator center shows the resumable-draft prompt after saving. 274 */ 275 async function waitForDraftResult(page, creationId) { 276 let lastState = { href: '', bodyText: '' }; 277 for (let attempt = 0; attempt < 20; attempt += 1) { 278 lastState = (await page.evaluate(`() => ({ 279 href: location.href, 280 bodyText: document.body?.innerText || '' 281 })`)); 282 if (lastState.href.includes('/creator-micro/content/upload') 283 && /继续编辑/.test(lastState.bodyText)) { 284 return creationId; 285 } 286 await page.wait({ time: 1 }); 287 } 288 throw new CommandExecutionError('未检测到抖音草稿恢复提示', `当前页面: ${lastState.href || 'unknown'}`); 289 } 290 cli({ 291 site: 'douyin', 292 name: 'draft', 293 description: '上传视频并保存为草稿', 294 domain: 'creator.douyin.com', 295 strategy: Strategy.COOKIE, 296 navigateBefore: false, 297 args: [ 298 { name: 'video', required: true, positional: true, help: '视频文件路径' }, 299 { name: 'title', required: true, help: '视频标题(≤30字)' }, 300 { name: 'caption', default: '', help: '正文内容(≤1000字,支持 #话题)' }, 301 { name: 'cover', default: '', help: '封面图片路径' }, 302 { name: 'visibility', default: 'public', choices: ['public', 'friends', 'private'] }, 303 ], 304 columns: ['status', 'draft_id'], 305 func: async (page, kwargs) => { 306 const videoPath = path.resolve(kwargs.video); 307 if (!fs.existsSync(videoPath)) { 308 throw new ArgumentError(`视频文件不存在: ${videoPath}`); 309 } 310 const ext = path.extname(videoPath).toLowerCase(); 311 if (!['.mp4', '.mov', '.avi', '.webm'].includes(ext)) { 312 throw new ArgumentError(`不支持的视频格式: ${ext}(支持 mp4/mov/avi/webm)`); 313 } 314 const title = kwargs.title; 315 if (title.length > 30) { 316 throw new ArgumentError('标题不能超过 30 字'); 317 } 318 const caption = kwargs.caption || ''; 319 if (caption.length > 1000) { 320 throw new ArgumentError('正文不能超过 1000 字'); 321 } 322 const coverPath = kwargs.cover; 323 if (coverPath) { 324 if (!fs.existsSync(path.resolve(coverPath))) { 325 throw new ArgumentError(`封面文件不存在: ${path.resolve(coverPath)}`); 326 } 327 } 328 if (!page.setFileInput) { 329 throw new CommandExecutionError('当前浏览器适配器不支持文件注入', '请使用 Browser Bridge 或支持 setFileInput 的浏览器模式'); 330 } 331 const visibilityLabel = VISIBILITY_LABELS[kwargs.visibility] ?? VISIBILITY_LABELS.public; 332 await page.goto(DRAFT_UPLOAD_URL); 333 await page.wait({ selector: 'input[type="file"]', timeout: 20 }); 334 await dismissKnownModals(page); 335 await page.setFileInput([videoPath], 'input[type="file"]'); 336 await waitForDraftComposer(page); 337 await dismissKnownModals(page); 338 if (coverPath) { 339 const coverSelector = await prepareCustomCoverInput(page); 340 await page.setFileInput([path.resolve(coverPath)], coverSelector); 341 await waitForCoverReady(page); 342 } 343 await fillDraftComposer(page, { title, caption, visibilityLabel }); 344 await page.wait({ time: 1 }); 345 const saveResult = await clickSaveDraft(page); 346 const draftId = await waitForDraftResult(page, saveResult.creationId); 347 return [ 348 { 349 status: '✅ 草稿已保存,可在创作中心继续编辑', 350 draft_id: draftId, 351 }, 352 ]; 353 }, 354 });