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
342 lines
16 KiB
JavaScript
342 lines
16 KiB
JavaScript
/**
|
||
* Douyin publish — 8-phase pipeline for scheduling video posts.
|
||
*
|
||
* Phases:
|
||
* 1. upload auth v5 credentials
|
||
* 2. Apply TOS upload URL
|
||
* 3. TOS multipart upload
|
||
* 4. Cover upload (optional, via ImageX)
|
||
* 5. Enable video
|
||
* 6. Poll transcode
|
||
* 7. Content safety check
|
||
* 8. create_v2 publish
|
||
*/
|
||
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';
|
||
import { getUploadAuthV5Credentials, applyVideoUploadInner, commitVideoUploadInner } from './_shared/vod-upload.js';
|
||
import { tosUpload } from './_shared/tos-upload.js';
|
||
import { imagexUpload } from './_shared/imagex-upload.js';
|
||
import { browserFetch } from './_shared/browser-fetch.js';
|
||
import { requireObjectEvaluateResult } from './_shared/evaluate-result.js';
|
||
import { generateCreationId } from './_shared/creation-id.js';
|
||
import { validateTiming, toUnixSeconds } from './_shared/timing.js';
|
||
import { parseTextExtra, extractHashtagNames } from './_shared/text-extra.js';
|
||
const VISIBILITY_MAP = {
|
||
public: 0,
|
||
friends: 1,
|
||
private: 2,
|
||
};
|
||
const IMAGEX_BASE = 'https://imagex.bytedanceapi.com';
|
||
const IMAGEX_SERVICE_ID = '1147';
|
||
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';
|
||
const DEFAULT_COVER_TOOLS_INFO = JSON.stringify({
|
||
video_cover_source: 2,
|
||
cover_timestamp: 0,
|
||
recommend_timestamp: 0,
|
||
is_cover_edit: 0,
|
||
is_cover_template: 0,
|
||
cover_template_id: '',
|
||
is_text_template: 0,
|
||
text_template_id: '',
|
||
text_template_content: '',
|
||
is_text: 0,
|
||
text_num: 0,
|
||
text_content: '',
|
||
is_use_sticker: 0,
|
||
sticker_id: '',
|
||
is_use_filter: 0,
|
||
filter_id: '',
|
||
is_cover_modify: 0,
|
||
to_status: 0,
|
||
cover_type: 0,
|
||
initial_cover_uri: '',
|
||
cut_coordinate: '',
|
||
});
|
||
function isFastDetectRetryable(error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
return message.includes('post_assistant/fast_detect') && (message.includes('Empty response') || message.includes('404') || message.includes('Not Found') || message.includes('Timeout') || message.includes('timed out') || message.includes('Failed to fetch'));
|
||
}
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
function throwIfImagexError(action, payload) {
|
||
const error = payload?.ResponseMetadata?.Error ?? payload?.Error;
|
||
if (error) {
|
||
throw new CommandExecutionError(`${action}失败: ${JSON.stringify(error)}`);
|
||
}
|
||
}
|
||
async function tryFastDetectFetch(page, method, url, options) {
|
||
let lastError;
|
||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||
try {
|
||
return { ok: true, value: await browserFetch(page, method, url, options) };
|
||
} catch (error) {
|
||
if (!isFastDetectRetryable(error)) {
|
||
throw error;
|
||
}
|
||
lastError = error;
|
||
if (attempt < 3) {
|
||
await sleep(500 * attempt);
|
||
}
|
||
}
|
||
}
|
||
return { ok: false, error: lastError };
|
||
}
|
||
cli({
|
||
site: 'douyin',
|
||
name: 'publish',
|
||
access: 'write',
|
||
description: '定时发布视频到抖音(必须设置 2h ~ 14天后的发布时间)',
|
||
domain: 'creator.douyin.com',
|
||
strategy: Strategy.COOKIE,
|
||
args: [
|
||
{ name: 'video', required: true, positional: true, help: '视频文件路径' },
|
||
{ name: 'title', required: true, help: '视频标题(≤30字)' },
|
||
{ name: 'schedule', required: true, help: '定时发布时间(ISO8601 或 Unix 秒,2h ~ 14天后)' },
|
||
{ name: 'caption', default: '', help: '正文内容(≤1000字,支持 #话题)' },
|
||
{ name: 'cover', default: '', help: '封面图片路径(不提供时使用视频截帧)' },
|
||
{ name: 'visibility', default: 'public', choices: ['public', 'friends', 'private'] },
|
||
{ name: 'allow_download', type: 'bool', default: false, help: '允许下载' },
|
||
{ name: 'collection', default: '', help: '合集 ID' },
|
||
{ name: 'activity', default: '', help: '活动 ID' },
|
||
{ name: 'poi_id', default: '', help: '地理位置 ID' },
|
||
{ name: 'poi_name', default: '', help: '地理位置名称' },
|
||
{ name: 'hotspot', default: '', help: '关联热点词' },
|
||
{ name: 'no_safety_check', type: 'bool', default: false, help: '跳过内容安全检测' },
|
||
{ name: 'sync_toutiao', type: 'bool', default: false, help: '同步发布到头条' },
|
||
],
|
||
columns: ['status', 'aweme_id', 'url', 'publish_time'],
|
||
func: async (page, kwargs) => {
|
||
// ── Fail-fast validation ────────────────────────────────────────────
|
||
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 fileSize = fs.statSync(videoPath).size;
|
||
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 timingTs = toUnixSeconds(kwargs.schedule);
|
||
validateTiming(timingTs);
|
||
const visibilityType = VISIBILITY_MAP[kwargs.visibility] ?? 0;
|
||
const coverPath = kwargs.cover;
|
||
if (coverPath) {
|
||
if (!fs.existsSync(path.resolve(coverPath))) {
|
||
throw new ArgumentError(`封面文件不存在: ${path.resolve(coverPath)}`);
|
||
}
|
||
}
|
||
// ── Phase 1: upload credentials ────────────────────────────────────
|
||
const credentials = await getUploadAuthV5Credentials(page);
|
||
// ── Phase 2: Apply TOS upload URL ───────────────────────────────────
|
||
const tosUploadInfo = await applyVideoUploadInner(fileSize, credentials);
|
||
let coverUri = '';
|
||
let coverWidth = 720;
|
||
let coverHeight = 1280;
|
||
// ── Phase 3: TOS upload ─────────────────────────────────────────────
|
||
await tosUpload({
|
||
filePath: videoPath,
|
||
uploadInfo: tosUploadInfo,
|
||
credentials,
|
||
onProgress: (uploaded, total) => {
|
||
const pct = Math.round((uploaded / total) * 100);
|
||
process.stderr.write(`\r 上传进度: ${pct}%`);
|
||
},
|
||
});
|
||
process.stderr.write('\n');
|
||
process.stderr.write(' 提交上传...\n');
|
||
const committedVideo = await commitVideoUploadInner(tosUploadInfo, credentials);
|
||
const videoId = committedVideo.video_id;
|
||
process.stderr.write(` 上传已提交: ${videoId}\n`);
|
||
coverWidth = committedVideo.width || coverWidth;
|
||
coverHeight = committedVideo.height || coverHeight;
|
||
if (!coverUri && committedVideo.poster_uri) {
|
||
coverUri = committedVideo.poster_uri;
|
||
}
|
||
// ── Phase 4: Cover upload (optional) ────────────────────────────────
|
||
if (kwargs.cover) {
|
||
const resolvedCoverPath = path.resolve(kwargs.cover);
|
||
// 4A: Apply ImageX upload
|
||
const applyUrl = `${IMAGEX_BASE}/?Action=ApplyImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01&UploadNum=1`;
|
||
const applyJs = `fetch(${JSON.stringify(applyUrl)}, { credentials: 'include' }).then(r => r.json())`;
|
||
const applyRes = requireObjectEvaluateResult(await page.evaluate(applyJs), '抖音封面申请上传地址响应异常');
|
||
throwIfImagexError('抖音封面申请上传地址', applyRes);
|
||
const imgStoreInfo = applyRes.Result?.UploadAddress?.StoreInfos?.[0];
|
||
if (!imgStoreInfo?.UploadHost || !imgStoreInfo?.StoreUri) {
|
||
throw new CommandExecutionError(`抖音封面申请上传地址响应缺少 UploadHost/StoreUri: ${JSON.stringify(applyRes).slice(0, 500)}`);
|
||
}
|
||
const imgUploadUrl = `https://${imgStoreInfo.UploadHost}/${imgStoreInfo.StoreUri}`;
|
||
// 4B: Upload image
|
||
const coverStoreUri = await imagexUpload(resolvedCoverPath, {
|
||
upload_url: imgUploadUrl,
|
||
store_uri: imgStoreInfo.StoreUri,
|
||
});
|
||
// 4C: Commit ImageX upload
|
||
const commitUrl = `${IMAGEX_BASE}/?Action=CommitImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01`;
|
||
const commitBody = JSON.stringify({ SuccessObjKeys: [coverStoreUri] });
|
||
const commitJs = `
|
||
fetch(${JSON.stringify(commitUrl)}, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: ${JSON.stringify(commitBody)}
|
||
}).then(r => r.json())
|
||
`;
|
||
const commitRes = requireObjectEvaluateResult(await page.evaluate(commitJs), '抖音封面提交上传响应异常');
|
||
throwIfImagexError('抖音封面提交上传', commitRes);
|
||
coverUri = coverStoreUri;
|
||
}
|
||
// The gateway upload flow returns a committed VOD upload result; the legacy
|
||
// enable/transend endpoints can hang for that flow, so create_v2 consumes
|
||
// the committed video_id and poster metadata directly.
|
||
// ── Phase 7: Content safety check ───────────────────────────────────
|
||
if (!kwargs.no_safety_check) {
|
||
const safetyUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/pre_check';
|
||
const safetyBody = {
|
||
video_id: videoId,
|
||
title,
|
||
desc: caption,
|
||
};
|
||
const preCheck = await tryFastDetectFetch(page, 'POST', safetyUrl, { body: safetyBody });
|
||
if (!preCheck.ok) {
|
||
process.stderr.write(' 内容安全预检接口无响应,继续轮询检测结果。\n');
|
||
}
|
||
const pollUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/poll';
|
||
const deadline = Date.now() + 30_000;
|
||
let safetyPassed = false;
|
||
let pollUnavailableCount = 0;
|
||
while (Date.now() < deadline) {
|
||
const poll = await tryFastDetectFetch(page, 'POST', pollUrl, { body: safetyBody });
|
||
if (!poll.ok) {
|
||
pollUnavailableCount += 1;
|
||
if (!preCheck.ok && pollUnavailableCount >= 3) {
|
||
break;
|
||
}
|
||
await sleep(2000);
|
||
continue;
|
||
}
|
||
pollUnavailableCount = 0;
|
||
const pollRes = poll.value;
|
||
if (pollRes.status === 0 || (pollRes.has_done === true && pollRes.detect_result?.reason_code === 0 && (pollRes.detect_list?.length ?? 0) === 0)) {
|
||
safetyPassed = true;
|
||
break;
|
||
}
|
||
if (pollRes.status === 1) {
|
||
throw new CommandExecutionError('内容安全检测不通过,请修改后重试', '使用 --no_safety_check 跳过');
|
||
}
|
||
await sleep(2000);
|
||
}
|
||
if (!safetyPassed) {
|
||
if (!preCheck.ok && pollUnavailableCount >= 3) {
|
||
process.stderr.write(' 内容安全预检持续无响应,跳过本地预检,交由 create_v2 后的平台审核。\n');
|
||
}
|
||
else {
|
||
throw new CommandExecutionError('内容安全检测超时(30s),请稍后重试', '如确认要跳过本地预检,可使用 --no_safety_check;提交后仍会走抖音平台审核');
|
||
}
|
||
}
|
||
}
|
||
// ── Phase 8: create_v2 publish ──────────────────────────────────────
|
||
const hashtagNames = extractHashtagNames(caption);
|
||
const hashtags = [];
|
||
let searchFrom = 0;
|
||
for (const name of hashtagNames) {
|
||
const idx = caption.indexOf(`#${name}`, searchFrom);
|
||
if (idx === -1)
|
||
continue;
|
||
hashtags.push({ name, id: 0, start: idx, end: idx + name.length + 1 });
|
||
searchFrom = idx + name.length + 1;
|
||
}
|
||
const publishText = caption ? `${title} ${caption}` : title;
|
||
const captionOffset = caption ? title.length + 1 : 0;
|
||
const textExtraArr = parseTextExtra(publishText, hashtags.map((hashtag) => ({
|
||
...hashtag,
|
||
start: hashtag.start + captionOffset,
|
||
end: hashtag.end + captionOffset,
|
||
})));
|
||
const publishBody = {
|
||
item: {
|
||
common: {
|
||
text: publishText,
|
||
caption: caption,
|
||
item_title: title,
|
||
activity: JSON.stringify(kwargs.activity ? [kwargs.activity] : []),
|
||
text_extra: JSON.stringify(textExtraArr),
|
||
challenges: '[]',
|
||
mentions: '[]',
|
||
hashtag_source: '',
|
||
hot_sentence: kwargs.hotspot || '',
|
||
interaction_stickers: '[]',
|
||
visibility_type: visibilityType,
|
||
download: kwargs.allow_download ? 1 : 0,
|
||
timing: timingTs,
|
||
creation_id: generateCreationId(),
|
||
media_type: 4,
|
||
video_id: videoId,
|
||
music_source: 0,
|
||
music_id: null,
|
||
...(kwargs.poi_id
|
||
? { poi_id: kwargs.poi_id, poi_name: kwargs.poi_name }
|
||
: {}),
|
||
},
|
||
cover: {
|
||
poster: coverUri,
|
||
custom_cover_image_height: coverHeight,
|
||
custom_cover_image_width: coverWidth,
|
||
poster_delay: 0,
|
||
cover_tools_info: DEFAULT_COVER_TOOLS_INFO,
|
||
cover_tools_extend_info: '{}',
|
||
},
|
||
mix: kwargs.collection
|
||
? { mix_id: kwargs.collection, mix_order: 0 }
|
||
: {},
|
||
chapter: {
|
||
chapter: JSON.stringify({
|
||
chapter_abstract: '',
|
||
chapter_details: [],
|
||
chapter_type: 0,
|
||
}),
|
||
},
|
||
anchor: {},
|
||
sync: {
|
||
should_sync: false,
|
||
sync_to_toutiao: kwargs.sync_toutiao ? 1 : 0,
|
||
},
|
||
open_platform: {},
|
||
assistant: { is_preview: 0, is_post_assistant: 1 },
|
||
declare: { user_declare_info: '{}' },
|
||
},
|
||
};
|
||
const publishUrl = `https://creator.douyin.com/web/api/media/aweme/create_v2/?read_aid=2906&${DEVICE_PARAMS}`;
|
||
process.stderr.write(' 创建定时发布...\n');
|
||
const publishRes = (await browserFetch(page, 'POST', publishUrl, {
|
||
body: publishBody,
|
||
}));
|
||
const awemeId = publishRes.aweme_id ?? publishRes.item_id;
|
||
if (!awemeId) {
|
||
throw new CommandExecutionError(`发布成功但未返回 aweme_id/item_id: ${JSON.stringify(publishRes)}`);
|
||
}
|
||
const url = `https://www.douyin.com/video/${awemeId}`;
|
||
const publishTimeStr = new Date(timingTs * 1000).toLocaleString('zh-CN', {
|
||
timeZone: 'Asia/Tokyo',
|
||
});
|
||
return [
|
||
{
|
||
status: '✅ 定时发布成功!',
|
||
aweme_id: awemeId,
|
||
url,
|
||
publish_time: publishTimeStr,
|
||
},
|
||
];
|
||
},
|
||
});
|