/ clis / douyin / draft.js
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  });