/ clis / douyin / publish.js
publish.js
  1  /**
  2   * Douyin publish — 8-phase pipeline for scheduling video posts.
  3   *
  4   * Phases:
  5   *   1. STS2 credentials
  6   *   2. Apply TOS upload URL
  7   *   3. TOS multipart upload
  8   *   4. Cover upload (optional, via ImageX)
  9   *   5. Enable video
 10   *   6. Poll transcode
 11   *   7. Content safety check
 12   *   8. create_v2 publish
 13   */
 14  import * as fs from 'node:fs';
 15  import * as path from 'node:path';
 16  import { cli, Strategy } from '@jackwener/opencli/registry';
 17  import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
 18  import { getSts2Credentials } from './_shared/sts2.js';
 19  import { tosUpload } from './_shared/tos-upload.js';
 20  import { imagexUpload } from './_shared/imagex-upload.js';
 21  import { pollTranscode } from './_shared/transcode.js';
 22  import { browserFetch } from './_shared/browser-fetch.js';
 23  import { generateCreationId } from './_shared/creation-id.js';
 24  import { validateTiming, toUnixSeconds } from './_shared/timing.js';
 25  import { parseTextExtra, extractHashtagNames } from './_shared/text-extra.js';
 26  const VISIBILITY_MAP = {
 27      public: 0,
 28      friends: 1,
 29      private: 2,
 30  };
 31  const IMAGEX_BASE = 'https://imagex.bytedanceapi.com';
 32  const IMAGEX_SERVICE_ID = '1147';
 33  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';
 34  const DEFAULT_COVER_TOOLS_INFO = JSON.stringify({
 35      video_cover_source: 2,
 36      cover_timestamp: 0,
 37      recommend_timestamp: 0,
 38      is_cover_edit: 0,
 39      is_cover_template: 0,
 40      cover_template_id: '',
 41      is_text_template: 0,
 42      text_template_id: '',
 43      text_template_content: '',
 44      is_text: 0,
 45      text_num: 0,
 46      text_content: '',
 47      is_use_sticker: 0,
 48      sticker_id: '',
 49      is_use_filter: 0,
 50      filter_id: '',
 51      is_cover_modify: 0,
 52      to_status: 0,
 53      cover_type: 0,
 54      initial_cover_uri: '',
 55      cut_coordinate: '',
 56  });
 57  cli({
 58      site: 'douyin',
 59      name: 'publish',
 60      description: '定时发布视频到抖音(必须设置 2h ~ 14天后的发布时间)',
 61      domain: 'creator.douyin.com',
 62      strategy: Strategy.COOKIE,
 63      args: [
 64          { name: 'video', required: true, positional: true, help: '视频文件路径' },
 65          { name: 'title', required: true, help: '视频标题(≤30字)' },
 66          { name: 'schedule', required: true, help: '定时发布时间(ISO8601 或 Unix 秒,2h ~ 14天后)' },
 67          { name: 'caption', default: '', help: '正文内容(≤1000字,支持 #话题)' },
 68          { name: 'cover', default: '', help: '封面图片路径(不提供时使用视频截帧)' },
 69          { name: 'visibility', default: 'public', choices: ['public', 'friends', 'private'] },
 70          { name: 'allow_download', type: 'bool', default: false, help: '允许下载' },
 71          { name: 'collection', default: '', help: '合集 ID' },
 72          { name: 'activity', default: '', help: '活动 ID' },
 73          { name: 'poi_id', default: '', help: '地理位置 ID' },
 74          { name: 'poi_name', default: '', help: '地理位置名称' },
 75          { name: 'hotspot', default: '', help: '关联热点词' },
 76          { name: 'no_safety_check', type: 'bool', default: false, help: '跳过内容安全检测' },
 77          { name: 'sync_toutiao', type: 'bool', default: false, help: '同步发布到头条' },
 78      ],
 79      columns: ['status', 'aweme_id', 'url', 'publish_time'],
 80      func: async (page, kwargs) => {
 81          // ── Fail-fast validation ────────────────────────────────────────────
 82          const videoPath = path.resolve(kwargs.video);
 83          if (!fs.existsSync(videoPath)) {
 84              throw new ArgumentError(`视频文件不存在: ${videoPath}`);
 85          }
 86          const ext = path.extname(videoPath).toLowerCase();
 87          if (!['.mp4', '.mov', '.avi', '.webm'].includes(ext)) {
 88              throw new ArgumentError(`不支持的视频格式: ${ext}(支持 mp4/mov/avi/webm)`);
 89          }
 90          const fileSize = fs.statSync(videoPath).size;
 91          const title = kwargs.title;
 92          if (title.length > 30) {
 93              throw new ArgumentError('标题不能超过 30 字');
 94          }
 95          const caption = kwargs.caption || '';
 96          if (caption.length > 1000) {
 97              throw new ArgumentError('正文不能超过 1000 字');
 98          }
 99          const timingTs = toUnixSeconds(kwargs.schedule);
100          validateTiming(timingTs);
101          const visibilityType = VISIBILITY_MAP[kwargs.visibility] ?? 0;
102          const coverPath = kwargs.cover;
103          if (coverPath) {
104              if (!fs.existsSync(path.resolve(coverPath))) {
105                  throw new ArgumentError(`封面文件不存在: ${path.resolve(coverPath)}`);
106              }
107          }
108          // ── Phase 1: STS2 credentials ───────────────────────────────────────
109          const credentials = await getSts2Credentials(page);
110          // ── Phase 2: Apply TOS upload URL ───────────────────────────────────
111          const vodUrl = `https://vod.bytedanceapi.com/?Action=ApplyVideoUpload&ServiceId=1128&Version=2021-01-01&FileType=video&FileSize=${fileSize}`;
112          const vodJs = `fetch(${JSON.stringify(vodUrl)}, { credentials: 'include' }).then(r => r.json())`;
113          const vodRes = (await page.evaluate(vodJs));
114          const { VideoId: videoId, UploadHosts, StoreInfos } = vodRes.Result.UploadAddress;
115          const tosUrl = `https://${UploadHosts[0]}/${StoreInfos[0].StoreUri}`;
116          const tosUploadInfo = {
117              tos_upload_url: tosUrl,
118              auth: StoreInfos[0].Auth,
119              video_id: videoId,
120          };
121          // ── Phase 3: TOS upload ─────────────────────────────────────────────
122          await tosUpload({
123              filePath: videoPath,
124              uploadInfo: tosUploadInfo,
125              credentials,
126              onProgress: (uploaded, total) => {
127                  const pct = Math.round((uploaded / total) * 100);
128                  process.stderr.write(`\r  上传进度: ${pct}%`);
129              },
130          });
131          process.stderr.write('\n');
132          // ── Phase 4: Cover upload (optional) ────────────────────────────────
133          let coverUri = '';
134          let coverWidth = 720;
135          let coverHeight = 1280;
136          if (kwargs.cover) {
137              const resolvedCoverPath = path.resolve(kwargs.cover);
138              // 4A: Apply ImageX upload
139              const applyUrl = `${IMAGEX_BASE}/?Action=ApplyImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01&UploadNum=1`;
140              const applyJs = `fetch(${JSON.stringify(applyUrl)}, { credentials: 'include' }).then(r => r.json())`;
141              const applyRes = (await page.evaluate(applyJs));
142              const { StoreInfos: imgStoreInfos } = applyRes.Result.UploadAddress;
143              const imgUploadUrl = `https://${imgStoreInfos[0].UploadHost}/${imgStoreInfos[0].StoreUri}`;
144              // 4B: Upload image
145              const coverStoreUri = await imagexUpload(resolvedCoverPath, {
146                  upload_url: imgUploadUrl,
147                  store_uri: imgStoreInfos[0].StoreUri,
148              });
149              // 4C: Commit ImageX upload
150              const commitUrl = `${IMAGEX_BASE}/?Action=CommitImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01`;
151              const commitBody = JSON.stringify({ SuccessObjKeys: [coverStoreUri] });
152              const commitJs = `
153          fetch(${JSON.stringify(commitUrl)}, {
154            method: 'POST',
155            credentials: 'include',
156            headers: { 'Content-Type': 'application/json' },
157            body: ${JSON.stringify(commitBody)}
158          }).then(r => r.json())
159        `;
160              await page.evaluate(commitJs);
161              coverUri = coverStoreUri;
162          }
163          // ── Phase 5: Enable video ───────────────────────────────────────────
164          const enableUrl = `https://creator.douyin.com/web/api/media/video/enable/?video_id=${videoId}&aid=1128`;
165          await browserFetch(page, 'GET', enableUrl);
166          // ── Phase 6: Poll transcode ─────────────────────────────────────────
167          const transResult = await pollTranscode(page, videoId);
168          coverWidth = transResult.width;
169          coverHeight = transResult.height;
170          if (!coverUri) {
171              coverUri = transResult.poster_uri;
172          }
173          // ── Phase 7: Content safety check ───────────────────────────────────
174          if (!kwargs.no_safety_check) {
175              const safetyUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/pre_check';
176              const safetyBody = {
177                  video_id: videoId,
178                  title,
179                  desc: caption,
180              };
181              await browserFetch(page, 'POST', safetyUrl, { body: safetyBody });
182              const pollUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/poll';
183              const deadline = Date.now() + 30_000;
184              let safetyPassed = false;
185              while (Date.now() < deadline) {
186                  const pollRes = (await browserFetch(page, 'POST', pollUrl, {
187                      body: safetyBody,
188                  }));
189                  if (pollRes.status === 0) {
190                      safetyPassed = true;
191                      break;
192                  }
193                  if (pollRes.status === 1) {
194                      throw new CommandExecutionError('内容安全检测不通过,请修改后重试', '使用 --no_safety_check 跳过');
195                  }
196                  await new Promise((r) => setTimeout(r, 2000));
197              }
198              if (!safetyPassed) {
199                  throw new CommandExecutionError('内容安全检测超时(30s),请稍后重试', '使用 --no_safety_check 跳过');
200              }
201          }
202          // ── Phase 8: create_v2 publish ──────────────────────────────────────
203          const hashtagNames = extractHashtagNames(caption);
204          const hashtags = [];
205          let searchFrom = 0;
206          for (const name of hashtagNames) {
207              const idx = caption.indexOf(`#${name}`, searchFrom);
208              if (idx === -1)
209                  continue;
210              hashtags.push({ name, id: 0, start: idx, end: idx + name.length + 1 });
211              searchFrom = idx + name.length + 1;
212          }
213          const textExtraArr = parseTextExtra(caption, hashtags);
214          const publishBody = {
215              item: {
216                  common: {
217                      text: caption,
218                      caption: '',
219                      item_title: title,
220                      activity: JSON.stringify(kwargs.activity ? [kwargs.activity] : []),
221                      text_extra: JSON.stringify(textExtraArr),
222                      challenges: '[]',
223                      mentions: '[]',
224                      hashtag_source: '',
225                      hot_sentence: kwargs.hotspot || '',
226                      interaction_stickers: '[]',
227                      visibility_type: visibilityType,
228                      download: kwargs.allow_download ? 1 : 0,
229                      timing: timingTs,
230                      creation_id: generateCreationId(),
231                      media_type: 4,
232                      video_id: videoId,
233                      music_source: 0,
234                      music_id: null,
235                      ...(kwargs.poi_id
236                          ? { poi_id: kwargs.poi_id, poi_name: kwargs.poi_name }
237                          : {}),
238                  },
239                  cover: {
240                      poster: coverUri,
241                      custom_cover_image_height: coverHeight,
242                      custom_cover_image_width: coverWidth,
243                      poster_delay: 0,
244                      cover_tools_info: DEFAULT_COVER_TOOLS_INFO,
245                      cover_tools_extend_info: '{}',
246                  },
247                  mix: kwargs.collection
248                      ? { mix_id: kwargs.collection, mix_order: 0 }
249                      : {},
250                  chapter: {
251                      chapter: JSON.stringify({
252                          chapter_abstract: '',
253                          chapter_details: [],
254                          chapter_type: 0,
255                      }),
256                  },
257                  anchor: {},
258                  sync: {
259                      should_sync: false,
260                      sync_to_toutiao: kwargs.sync_toutiao ? 1 : 0,
261                  },
262                  open_platform: {},
263                  assistant: { is_preview: 0, is_post_assistant: 1 },
264                  declare: { user_declare_info: '{}' },
265              },
266          };
267          const publishUrl = `https://creator.douyin.com/web/api/media/aweme/create_v2/?read_aid=2906&${DEVICE_PARAMS}`;
268          const publishRes = (await browserFetch(page, 'POST', publishUrl, {
269              body: publishBody,
270          }));
271          const awemeId = publishRes.aweme_id;
272          if (!awemeId) {
273              throw new CommandExecutionError(`发布成功但未返回 aweme_id: ${JSON.stringify(publishRes)}`);
274          }
275          const url = `https://www.douyin.com/video/${awemeId}`;
276          const publishTimeStr = new Date(timingTs * 1000).toLocaleString('zh-CN', {
277              timeZone: 'Asia/Tokyo',
278          });
279          return [
280              {
281                  status: '✅ 定时发布成功!',
282                  aweme_id: awemeId,
283                  url,
284                  publish_time: publishTimeStr,
285              },
286          ];
287      },
288  });