项目文件夹

文件
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 行
16 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* 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,
},
];
},
});