9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
356 lines
14 KiB
JavaScript
356 lines
14 KiB
JavaScript
/**
|
||
* Douyin draft — upload through the official creator page and save as draft.
|
||
*
|
||
* The previous API pipeline relied on an old pre-upload endpoint that no longer
|
||
* matches creator center's live upload flow. This command now drives the
|
||
* official upload page directly so it stays aligned with the site.
|
||
*/
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||
const VISIBILITY_LABELS = {
|
||
public: '公开',
|
||
friends: '好友可见',
|
||
private: '仅自己可见',
|
||
};
|
||
const DRAFT_UPLOAD_URL = 'https://creator.douyin.com/creator-micro/content/upload';
|
||
const COMPOSER_WAIT_ATTEMPTS = 120;
|
||
const COVER_INPUT_WAIT_ATTEMPTS = 20;
|
||
const COVER_READY_WAIT_ATTEMPTS = 20;
|
||
/**
|
||
* Best-effort dismissal for coach marks and upload tips that can block clicks.
|
||
*/
|
||
async function dismissKnownModals(page) {
|
||
await page.evaluate(`() => {
|
||
const targets = ['我知道了', '知道了', '关闭'];
|
||
for (const text of targets) {
|
||
const btn = Array.from(document.querySelectorAll('button,[role="button"]'))
|
||
.find((el) => (el.textContent || '').trim() === text);
|
||
if (btn instanceof HTMLElement) btn.click();
|
||
}
|
||
}`);
|
||
}
|
||
/**
|
||
* Wait until Douyin finishes uploading and lands on the post-video composer.
|
||
*/
|
||
async function waitForDraftComposer(page) {
|
||
let lastState = {
|
||
href: '',
|
||
ready: false,
|
||
bodyText: '',
|
||
};
|
||
for (let attempt = 0; attempt < COMPOSER_WAIT_ATTEMPTS; attempt += 1) {
|
||
lastState = (await page.evaluate(`() => ({
|
||
href: location.href,
|
||
ready: !!Array.from(document.querySelectorAll('input')).find(
|
||
(el) => (el.placeholder || '').includes('填写作品标题')
|
||
) && !!Array.from(document.querySelectorAll('button')).find(
|
||
(el) => (el.textContent || '').includes('暂存离开')
|
||
),
|
||
bodyText: document.body?.innerText || ''
|
||
})`));
|
||
if (lastState.ready)
|
||
return;
|
||
await page.wait({ time: 0.5 });
|
||
}
|
||
throw new CommandExecutionError('等待抖音草稿编辑页超时', `当前页面: ${lastState.href || 'unknown'}`);
|
||
}
|
||
/**
|
||
* Fill title, caption and visibility controls on the live composer page.
|
||
*/
|
||
async function fillDraftComposer(page, options) {
|
||
const titleOk = (await page.evaluate(`() => {
|
||
const titleInput = Array.from(document.querySelectorAll('input')).find(
|
||
(el) => (el.placeholder || '').includes('填写作品标题')
|
||
);
|
||
if (!(titleInput instanceof HTMLInputElement)) return false;
|
||
const propKey = Object.keys(titleInput).find((key) => key.startsWith('__reactProps$'));
|
||
const props = propKey ? titleInput[propKey] : null;
|
||
if (props?.onChange) {
|
||
props.onChange({
|
||
target: { value: ${JSON.stringify(options.title)} },
|
||
currentTarget: { value: ${JSON.stringify(options.title)} },
|
||
});
|
||
} else {
|
||
titleInput.focus();
|
||
titleInput.value = ${JSON.stringify(options.title)};
|
||
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
titleInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
if (props?.onBlur) {
|
||
props.onBlur({
|
||
target: titleInput,
|
||
currentTarget: titleInput,
|
||
relatedTarget: null,
|
||
});
|
||
} else {
|
||
titleInput.dispatchEvent(new Event('blur', { bubbles: true }));
|
||
}
|
||
return true;
|
||
}`));
|
||
if (!titleOk) {
|
||
throw new CommandExecutionError('填写抖音草稿表单失败: title-input-missing');
|
||
}
|
||
if (options.caption) {
|
||
const captionOk = (await page.evaluate(`() => {
|
||
const editor = document.querySelector('[contenteditable="true"]');
|
||
if (!(editor instanceof HTMLElement)) return false;
|
||
editor.focus();
|
||
editor.textContent = '';
|
||
document.execCommand('selectAll', false);
|
||
document.execCommand('insertText', false, ${JSON.stringify(options.caption)});
|
||
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
||
return true;
|
||
}`));
|
||
if (!captionOk) {
|
||
throw new CommandExecutionError('填写抖音草稿表单失败: caption-editor-missing');
|
||
}
|
||
}
|
||
const visibilityOk = (await page.evaluate(`() => {
|
||
const visibility = Array.from(document.querySelectorAll('label')).find(
|
||
(el) => (el.textContent || '').includes(${JSON.stringify(options.visibilityLabel)})
|
||
);
|
||
if (!(visibility instanceof HTMLElement)) return false;
|
||
visibility.click();
|
||
return true;
|
||
}`));
|
||
if (!visibilityOk) {
|
||
throw new CommandExecutionError('填写抖音草稿表单失败: visibility-missing');
|
||
}
|
||
}
|
||
/**
|
||
* Switch the composer into custom-cover mode and expose the cover input with a
|
||
* stable selector for CDP file injection.
|
||
*/
|
||
async function prepareCustomCoverInput(page) {
|
||
let lastReason = 'cover-input-missing';
|
||
const baselineCount = (await page.evaluate(`() => Array.from(document.querySelectorAll('input[type="file"]')).length`));
|
||
for (let attempt = 0; attempt < COVER_INPUT_WAIT_ATTEMPTS; attempt += 1) {
|
||
const result = (await page.evaluate(`() => {
|
||
const coverLabel = Array.from(document.querySelectorAll('label')).find(
|
||
(el) => (el.textContent || '').includes('上传新封面')
|
||
);
|
||
if (coverLabel instanceof HTMLElement) {
|
||
coverLabel.click();
|
||
}
|
||
|
||
const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
|
||
const target = inputs
|
||
.slice(${JSON.stringify(baselineCount)})
|
||
.find((el) => el instanceof HTMLInputElement && !el.disabled);
|
||
if (!(target instanceof HTMLInputElement)) {
|
||
return { ok: false, reason: 'cover-input-pending' };
|
||
}
|
||
|
||
document
|
||
.querySelectorAll('[data-opencli-cover-input="1"]')
|
||
.forEach((el) => el.removeAttribute('data-opencli-cover-input'));
|
||
target.setAttribute('data-opencli-cover-input', '1');
|
||
return { ok: true, selector: '[data-opencli-cover-input="1"]' };
|
||
}`));
|
||
if (result?.ok && result.selector) {
|
||
return result.selector;
|
||
}
|
||
lastReason = result?.reason || lastReason;
|
||
await page.wait({ time: 0.5 });
|
||
}
|
||
throw new CommandExecutionError(`准备抖音自定义封面输入框失败: ${lastReason}`);
|
||
}
|
||
/**
|
||
* Read the local quick-check panel text that reflects cover validation state.
|
||
*/
|
||
export function buildCoverCheckPanelTextJs() {
|
||
return `() => {
|
||
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||
const stateTexts = ['检测', '检测中', '封面检测中', '重新检测', '横/竖双封面缺失'];
|
||
const marker = Array.from(document.querySelectorAll('div,span,p,button')).find(
|
||
(el) => normalize(el.textContent) === '快速检测'
|
||
);
|
||
let root = marker?.parentElement || null;
|
||
while (root && root !== document.body) {
|
||
const descendants = Array.from(root.querySelectorAll('div,span,p,button'))
|
||
.map((el) => normalize(el.textContent));
|
||
const hasMarkerText = descendants.includes('快速检测');
|
||
const hasStateText = descendants.some((text) => stateTexts.includes(text));
|
||
if (hasMarkerText && hasStateText) {
|
||
return normalize(root.textContent).slice(0, 400);
|
||
}
|
||
root = root.parentElement;
|
||
}
|
||
return '';
|
||
}`;
|
||
}
|
||
async function getCoverCheckPanelText(page) {
|
||
return (await page.evaluate(buildCoverCheckPanelTextJs())) || '';
|
||
}
|
||
/**
|
||
* Wait for Douyin's cover-detection pipeline to expose a post-upload signal.
|
||
* In the live creator page, custom cover upload first shows `封面检测中`, then
|
||
* lands on a ready state such as `重新检测` or the warning copy for missing
|
||
* horizontal/vertical covers.
|
||
*/
|
||
async function waitForCoverReady(page) {
|
||
let lastPanelText = '';
|
||
let sawBusy = false;
|
||
for (let attempt = 0; attempt < COVER_READY_WAIT_ATTEMPTS; attempt += 1) {
|
||
const panelText = await getCoverCheckPanelText(page);
|
||
const busy = panelText.includes('检测中');
|
||
const ready = (panelText.includes('重新检测')
|
||
|| panelText.includes('横/竖双封面缺失'));
|
||
if (busy) {
|
||
sawBusy = true;
|
||
}
|
||
if (sawBusy && ready && !busy) {
|
||
return;
|
||
}
|
||
lastPanelText = panelText;
|
||
await page.wait({ time: 0.5 });
|
||
}
|
||
throw new CommandExecutionError('等待抖音封面处理完成超时', lastPanelText || 'unknown');
|
||
}
|
||
/**
|
||
* Click the draft button on the composer page and extract the current creation id.
|
||
*/
|
||
async function clickSaveDraft(page) {
|
||
const result = (await page.evaluate(`() => {
|
||
const extractCreationId = () => {
|
||
const titleInput = Array.from(document.querySelectorAll('input')).find(
|
||
(el) => (el.placeholder || '').includes('填写作品标题')
|
||
);
|
||
if (!(titleInput instanceof HTMLInputElement)) return '';
|
||
|
||
const fiberKey = Object.keys(titleInput).find((key) => key.startsWith('__reactFiber$'));
|
||
let fiber = fiberKey ? titleInput[fiberKey] : null;
|
||
while (fiber) {
|
||
const props = fiber.memoizedProps;
|
||
if (typeof props?.creation_id === 'string' && props.creation_id) {
|
||
return props.creation_id;
|
||
}
|
||
fiber = fiber.return;
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const btn = Array.from(document.querySelectorAll('button')).find(
|
||
(el) => (el.textContent || '').includes('暂存离开')
|
||
);
|
||
if (!(btn instanceof HTMLButtonElement)) {
|
||
return { ok: false, reason: 'draft-button-missing' };
|
||
}
|
||
const creationId = extractCreationId();
|
||
const propKey = Object.keys(btn).find((key) => key.startsWith('__reactProps$'));
|
||
const props = propKey ? btn[propKey] : null;
|
||
if (props?.onClick) {
|
||
props.onClick({
|
||
preventDefault() {},
|
||
stopPropagation() {},
|
||
nativeEvent: null,
|
||
target: btn,
|
||
currentTarget: btn,
|
||
});
|
||
} else {
|
||
btn.click();
|
||
}
|
||
return {
|
||
ok: true,
|
||
text: (btn.textContent || '').trim(),
|
||
creationId,
|
||
};
|
||
}`));
|
||
if (!result?.ok) {
|
||
throw new CommandExecutionError(`点击草稿按钮失败: ${result?.reason || 'unknown'}`);
|
||
}
|
||
if (!result.creationId) {
|
||
throw new CommandExecutionError('点击草稿按钮失败: creation-id-missing');
|
||
}
|
||
return {
|
||
text: result.text || '暂存离开',
|
||
creationId: result.creationId,
|
||
};
|
||
}
|
||
/**
|
||
* Wait until creator center shows the resumable-draft prompt after saving.
|
||
*/
|
||
async function waitForDraftResult(page, creationId) {
|
||
let lastState = { href: '', bodyText: '' };
|
||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||
lastState = (await page.evaluate(`() => ({
|
||
href: location.href,
|
||
bodyText: document.body?.innerText || ''
|
||
})`));
|
||
if (lastState.href.includes('/creator-micro/content/upload')
|
||
&& /继续编辑/.test(lastState.bodyText)) {
|
||
return creationId;
|
||
}
|
||
await page.wait({ time: 1 });
|
||
}
|
||
throw new CommandExecutionError('未检测到抖音草稿恢复提示', `当前页面: ${lastState.href || 'unknown'}`);
|
||
}
|
||
cli({
|
||
site: 'douyin',
|
||
name: 'draft',
|
||
access: 'write',
|
||
description: '上传视频并保存为草稿',
|
||
domain: 'creator.douyin.com',
|
||
strategy: Strategy.COOKIE,
|
||
navigateBefore: false,
|
||
args: [
|
||
{ name: 'video', required: true, positional: true, help: '视频文件路径' },
|
||
{ name: 'title', required: true, help: '视频标题(≤30字)' },
|
||
{ name: 'caption', default: '', help: '正文内容(≤1000字,支持 #话题)' },
|
||
{ name: 'cover', default: '', help: '封面图片路径' },
|
||
{ name: 'visibility', default: 'public', choices: ['public', 'friends', 'private'] },
|
||
],
|
||
columns: ['status', 'draft_id'],
|
||
func: async (page, kwargs) => {
|
||
const videoPath = path.resolve(kwargs.video);
|
||
if (!fs.existsSync(videoPath)) {
|
||
throw new ArgumentError(`视频文件不存在: ${videoPath}`);
|
||
}
|
||
const ext = path.extname(videoPath).toLowerCase();
|
||
if (!['.mp4', '.mov', '.avi', '.webm'].includes(ext)) {
|
||
throw new ArgumentError(`不支持的视频格式: ${ext}(支持 mp4/mov/avi/webm)`);
|
||
}
|
||
const title = kwargs.title;
|
||
if (title.length > 30) {
|
||
throw new ArgumentError('标题不能超过 30 字');
|
||
}
|
||
const caption = kwargs.caption || '';
|
||
if (caption.length > 1000) {
|
||
throw new ArgumentError('正文不能超过 1000 字');
|
||
}
|
||
const coverPath = kwargs.cover;
|
||
if (coverPath) {
|
||
if (!fs.existsSync(path.resolve(coverPath))) {
|
||
throw new ArgumentError(`封面文件不存在: ${path.resolve(coverPath)}`);
|
||
}
|
||
}
|
||
if (!page.setFileInput) {
|
||
throw new CommandExecutionError('当前浏览器适配器不支持文件注入', '请使用 Browser Bridge 或支持 setFileInput 的浏览器模式');
|
||
}
|
||
const visibilityLabel = VISIBILITY_LABELS[kwargs.visibility] ?? VISIBILITY_LABELS.public;
|
||
await page.goto(DRAFT_UPLOAD_URL);
|
||
await page.wait({ selector: 'input[type="file"]', timeout: 20 });
|
||
await dismissKnownModals(page);
|
||
await page.setFileInput([videoPath], 'input[type="file"]');
|
||
await waitForDraftComposer(page);
|
||
await dismissKnownModals(page);
|
||
if (coverPath) {
|
||
const coverSelector = await prepareCustomCoverInput(page);
|
||
await page.setFileInput([path.resolve(coverPath)], coverSelector);
|
||
await waitForCoverReady(page);
|
||
}
|
||
await fillDraftComposer(page, { title, caption, visibilityLabel });
|
||
await page.wait({ time: 1 });
|
||
const saveResult = await clickSaveDraft(page);
|
||
const draftId = await waitForDraftResult(page, saveResult.creationId);
|
||
return [
|
||
{
|
||
status: '✅ 草稿已保存,可在创作中心继续编辑',
|
||
draft_id: draftId,
|
||
},
|
||
];
|
||
},
|
||
});
|