Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

342 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
},
];
},
});