import * as crypto from 'node:crypto'; import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; import { unwrapEvaluateResult } from './evaluate-result.js'; const AUTH_V5_URL = 'https://creator.douyin.com/web/api/media/upload/auth/v5/'; const VOD_UPLOAD_HOST = 'https://vod.bytedanceapi.com/'; const VOD_SPACE_NAME = 'aweme'; function hmacSha256(key, data) { return crypto.createHmac('sha256', key).update(data, 'utf8').digest(); } function sha256Hex(data) { const hash = crypto.createHash('sha256'); if (Buffer.isBuffer(data) || data instanceof Uint8Array) { hash.update(data); } else { hash.update(data ?? '', 'utf8'); } return hash.digest('hex'); } function nowDatetime() { return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z'); } function canonicalQuery(url) { return [...url.searchParams.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); } function computeAws4Headers(url, credentials, options = {}) { const parsedUrl = new URL(url); const datetime = nowDatetime(); const date = datetime.slice(0, 8); const method = options.method ?? 'GET'; const body = options.body ?? ''; const bodyHash = sha256Hex(body); const headers = { ...(options.headers ?? {}), host: parsedUrl.host, 'x-amz-content-sha256': bodyHash, 'x-amz-date': datetime, 'x-amz-security-token': credentials.session_token, }; const sortedHeaderKeys = Object.keys(headers).sort((a, b) => a.localeCompare(b)); const canonicalHeaders = sortedHeaderKeys .map((key) => `${key}:${String(headers[key]).trim()}`) .join('\n') + '\n'; const signedHeaders = sortedHeaderKeys.join(';'); const canonicalRequest = [ method, parsedUrl.pathname || '/', canonicalQuery(parsedUrl), canonicalHeaders, signedHeaders, bodyHash, ].join('\n'); const service = 'vod'; const region = 'cn-north-1'; const credentialScope = `${date}/${region}/${service}/aws4_request`; const stringToSign = [ 'AWS4-HMAC-SHA256', datetime, credentialScope, sha256Hex(canonicalRequest), ].join('\n'); const kDate = hmacSha256(`AWS4${credentials.secret_access_key}`, date); const kRegion = hmacSha256(kDate, region); const kService = hmacSha256(kRegion, service); const kSigning = hmacSha256(kService, 'aws4_request'); const signature = hmacSha256(kSigning, stringToSign).toString('hex'); return { ...headers, Authorization: `AWS4-HMAC-SHA256 Credential=${credentials.access_key_id}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, }; } function extractUserIdFromSessionToken(sessionToken) { try { const raw = sessionToken.startsWith('STS2') ? sessionToken.slice(4) : sessionToken; const decoded = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')); const policy = JSON.parse(decoded.PolicyString || '{}'); const condition = policy?.Statement?.[0]?.Condition; if (typeof condition === 'string') { const parsedCondition = JSON.parse(condition); return parsedCondition.UserId || ''; } } catch { return ''; } return ''; } export async function getUploadAuthV5Credentials(page) { const result = unwrapEvaluateResult(await page.evaluate(`fetch(${JSON.stringify(AUTH_V5_URL)}, { credentials: 'include' }).then(r => r.json())`)); if (!result || Array.isArray(result) || typeof result !== 'object') { throw new CommandExecutionError(`获取抖音上传授权失败: ${JSON.stringify(result)}`); } if (result.status_code !== 0) { const message = result.status_msg ?? result.message ?? 'unknown error'; if (result.status_code === 401 || result.status_code === 403 || /login|cookie|auth|captcha|verify|forbidden|permission|登录|登陆|权限|验证|验证码/i.test(String(message))) { throw new AuthRequiredError('creator.douyin.com', `获取抖音上传授权失败: ${message}`); } throw new CommandExecutionError(`获取抖音上传授权失败: ${JSON.stringify(result)}`); } if (!result.auth) { throw new CommandExecutionError(`获取抖音上传授权失败: ${JSON.stringify(result)}`); } let auth; try { auth = JSON.parse(result.auth); } catch (error) { throw new CommandExecutionError(`解析抖音上传授权失败: ${error instanceof Error ? error.message : String(error)}`); } if (!auth.AccessKeyID || !auth.SecretAccessKey || !auth.SessionToken) { throw new CommandExecutionError('抖音上传授权缺少 AccessKeyID/SecretAccessKey/SessionToken'); } return { access_key_id: auth.AccessKeyID, secret_access_key: auth.SecretAccessKey, session_token: auth.SessionToken, user_id: extractUserIdFromSessionToken(auth.SessionToken), expired_time: auth.ExpiredTime, current_time: auth.CurrentTime, }; } export async function applyVideoUploadInner(fileSize, credentials) { const params = new URLSearchParams({ Action: 'ApplyUploadInner', Version: '2020-11-19', SpaceName: VOD_SPACE_NAME, FileType: 'video', IsInner: '1', FileSize: String(fileSize), }); const url = `${VOD_UPLOAD_HOST}?${params.toString()}`; const res = await fetch(url, { headers: computeAws4Headers(url, credentials), signal: AbortSignal.timeout(30000) }); const text = await res.text(); let payload; try { payload = JSON.parse(text); } catch { throw new CommandExecutionError(`申请抖音上传地址失败,非 JSON 响应: HTTP ${res.status} ${text.slice(0, 300)}`); } const error = payload?.ResponseMetadata?.Error; if (!res.ok || error) { throw new CommandExecutionError(`申请抖音上传地址失败: HTTP ${res.status} ${JSON.stringify(error ?? payload)}`); } const uploadNode = payload?.Result?.InnerUploadAddress?.UploadNodes?.[0]; const storeInfo = uploadNode?.StoreInfos?.[0]; const videoId = payload?.Result?.Vid || uploadNode?.Vid; const sessionKey = uploadNode?.SessionKey ?? storeInfo?.SessionKey ?? payload?.Result?.SessionKey; if (!uploadNode?.UploadHost || !storeInfo?.StoreUri || !storeInfo?.Auth || !videoId || !sessionKey) { throw new CommandExecutionError(`申请抖音上传地址响应缺少必要字段: ${JSON.stringify(payload).slice(0, 500)}`); } return { video_id: videoId, tos_upload_url: `https://${uploadNode.UploadHost}/${storeInfo.StoreUri}`, auth: storeInfo.Auth, session_key: sessionKey, upload_header: uploadNode.UploadHeader ?? {}, user_id: credentials.user_id ?? '', }; } export async function commitVideoUploadInner(uploadInfo, credentials) { if (!uploadInfo?.session_key) { throw new CommandExecutionError('抖音上传提交缺少 SessionKey'); } const params = new URLSearchParams({ Action: 'CommitUploadInner', Version: '2020-11-19', SpaceName: VOD_SPACE_NAME, }); const url = `${VOD_UPLOAD_HOST}?${params.toString()}`; const body = JSON.stringify({ SessionKey: uploadInfo.session_key }); const headers = computeAws4Headers(url, credentials, { method: 'POST', body, headers: { 'content-type': 'application/json;charset=UTF-8' }, }); const res = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(30000) }); const text = await res.text(); let payload; try { payload = JSON.parse(text); } catch { throw new CommandExecutionError(`提交抖音上传失败,非 JSON 响应: HTTP ${res.status} ${text.slice(0, 300)}`); } const error = payload?.ResponseMetadata?.Error; if (!res.ok || error) { throw new CommandExecutionError(`提交抖音上传失败: HTTP ${res.status} ${JSON.stringify(error ?? payload)}`); } const result = payload?.Result?.Results?.[0] ?? payload?.Result ?? {}; const videoId = result.Vid ?? result.VideoId ?? result.VideoID ?? result.vid ?? uploadInfo.video_id; if (!videoId) { throw new CommandExecutionError(`提交抖音上传响应缺少 video id: ${JSON.stringify(payload).slice(0, 500)}`); } const meta = result.Meta ?? result.VideoMeta ?? {}; return { video_id: videoId, poster_uri: result.PosterUri ?? result.PosterURI ?? result.SnapshotUri ?? result.SnapshotURI ?? '', width: Number(meta.Width ?? meta.width ?? 720) || 720, height: Number(meta.Height ?? meta.height ?? 1280) || 1280, raw: result, }; }