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

213 lines
8.2 KiB
JavaScript

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,
};
}