skatef's picture
Add Jimeng API Space deployment.
461222a
import path from "path";
import _ from "lodash";
import mime from "mime";
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
import { HttpsProxyAgent } from "https-proxy-agent";
import { SocksProxyAgent } from "socks-proxy-agent";
import APIException from "@/lib/exceptions/APIException.ts";
import EX from "@/api/consts/exceptions.ts";
import logger from "@/lib/logger.ts";
import util from "@/lib/util.ts";
import { JimengErrorHandler, JimengErrorResponse } from "@/lib/error-handler.ts";
import { BASE_URL_DREAMINA_US, BASE_URL_DREAMINA_HK, DA_VERSION, WEB_VERSION } from "@/api/consts/dreamina.ts";
import {
BASE_URL_CN,
BASE_URL_US_COMMERCE,
BASE_URL_HK_COMMERCE,
BASE_URL_HK,
DEFAULT_ASSISTANT_ID_CN,
DEFAULT_ASSISTANT_ID_US,
DEFAULT_ASSISTANT_ID_HK,
DEFAULT_ASSISTANT_ID_JP,
DEFAULT_ASSISTANT_ID_SG,
PLATFORM_CODE,
REGION_CN,
REGION_US,
REGION_HK,
REGION_JP,
REGION_SG,
VERSION_CODE,
RETRY_CONFIG
} from "@/api/consts/common.ts";
// 模型名称
const MODEL_NAME = "jimeng";
// 设备ID
const DEVICE_ID = Math.random() * 999999999999999999 + 7000000000000000000;
// WebID
const WEB_ID = Math.random() * 999999999999999999 + 7000000000000000000;
// 用户ID(32位hex,无横线)
const USER_ID = util.uuid(false);
// 伪装headers
const FAKE_HEADERS = {
Accept: "application/json, text/plain, */*",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-language": "zh-CN,zh;q=0.9",
"Cache-control": "no-cache",
Appvr: VERSION_CODE,
Pragma: "no-cache",
Priority: "u=1, i",
Pf: PLATFORM_CODE,
"Sec-Ch-Ua": '"Google Chrome";v="142", "Chromium";v="142", "Not_A Brand";v="99"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"Windows"',
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
};
// 文件最大大小
const FILE_MAX_SIZE = 100 * 1024 * 1024;
/**
* 获取缓存中的access_token
*
* 目前jimeng的access_token是固定的,暂无刷新功能
*
* @param refreshToken 用于刷新access_token的refresh_token
*/
export async function acquireToken(refreshToken: string): Promise<string> {
return refreshToken;
}
/**
* 解析 token 中的地区信息
*
* @param refreshToken 刷新令牌
* @returns 地区信息对象
*/
export interface RegionInfo {
isUS: boolean;
isHK: boolean;
isJP: boolean;
isSG: boolean;
isInternational: boolean;
isCN: boolean;
}
export interface TokenWithProxy {
token: string;
proxyUrl: string | null;
}
export function parseProxyFromToken(rawToken: string): TokenWithProxy {
const tokenValue = rawToken.trim();
const proxyPattern = /^(https?|socks(?:4|5)?):\/\//i;
if (!proxyPattern.test(tokenValue)) return { token: tokenValue, proxyUrl: null };
const lastAtIndex = tokenValue.lastIndexOf("@");
if (lastAtIndex <= 0 || lastAtIndex === tokenValue.length - 1)
return { token: tokenValue, proxyUrl: null };
const proxyUrl = tokenValue.slice(0, lastAtIndex);
const token = tokenValue.slice(lastAtIndex + 1);
if (!proxyUrl || !token) return { token: tokenValue, proxyUrl: null };
return { token, proxyUrl };
}
export function parseRegionFromToken(refreshToken: string): RegionInfo {
const { token: parsedToken } = parseProxyFromToken(refreshToken);
const token = parsedToken.toLowerCase();
const isUS = token.startsWith('us-');
const isHK = token.startsWith('hk-');
const isJP = token.startsWith('jp-');
const isSG = token.startsWith('sg-');
const isInternational = isUS || isHK || isJP || isSG;
return {
isUS,
isHK,
isJP,
isSG,
isInternational,
isCN: !isInternational
};
}
/**
* 根据地区获取 Referer
*
* @param refreshToken 刷新令牌
* @param cnPath 国内站路径
* @returns Referer URL
*/
export function getRefererByRegion(refreshToken: string, cnPath: string): string {
const { isInternational } = parseRegionFromToken(refreshToken);
return isInternational
? "https://dreamina.capcut.com/"
: `https://jimeng.jianying.com${cnPath}`;
}
/**
* 根据地区获取 AssistantID
*
* @param regionInfo 地区信息
* @returns AssistantID
*/
export function getAssistantId(regionInfo: RegionInfo): number {
if (regionInfo.isUS) return DEFAULT_ASSISTANT_ID_US;
if (regionInfo.isJP) return DEFAULT_ASSISTANT_ID_JP;
if (regionInfo.isSG) return DEFAULT_ASSISTANT_ID_SG;
if (regionInfo.isHK) return DEFAULT_ASSISTANT_ID_HK;
return DEFAULT_ASSISTANT_ID_CN;
}
/**
* 生成cookie
*/
export function generateCookie(refreshToken: string) {
const { token: tokenWithRegion } = parseProxyFromToken(refreshToken);
const { isUS, isHK, isJP, isSG } = parseRegionFromToken(tokenWithRegion);
const token = (isUS || isHK || isJP || isSG)
? tokenWithRegion.substring(3)
: tokenWithRegion;
return [
`_tea_web_id=${WEB_ID}`,
`is_staff_user=false`,
`sid_guard=${token}%7C${util.unixTimestamp()}%7C5184000%7CMon%2C+03-Feb-2025+08%3A17%3A09+GMT`,
`uid_tt=${USER_ID}`,
`uid_tt_ss=${USER_ID}`,
`sid_tt=${token}`,
`sessionid=${token}`,
`sessionid_ss=${token}`,
].join("; ");
}
/**
* 获取积分信息
*
* @param refreshToken 用于刷新access_token的refresh_token
*/
export async function getCredit(refreshToken: string) {
const referer = getRefererByRegion(refreshToken, "/ai-tool/image/generate");
const {
credit: { gift_credit, purchase_credit, vip_credit }
} = await request("POST", "/commerce/v1/benefits/user_credit", refreshToken, {
data: {},
headers: {
Referer: referer,
},
noDefaultParams: true
});
logger.info(`\n积分信息: \n赠送积分: ${gift_credit}, 购买积分: ${purchase_credit}, VIP积分: ${vip_credit}`);
return {
giftCredit: gift_credit,
purchaseCredit: purchase_credit,
vipCredit: vip_credit,
totalCredit: gift_credit + purchase_credit + vip_credit
}
}
/**
* 接收今日积分(仅在积分为 0 时调用)
*
* @param refreshToken 用于刷新access_token的refresh_token
*/
export async function receiveCredit(refreshToken: string) {
logger.info("正在尝试收取今日积分...")
const referer = getRefererByRegion(refreshToken, "/ai-tool/home");
const { receive_quota } = await request("POST", "/commerce/v1/benefits/credit_receive", refreshToken, {
data: {
time_zone: "Asia/Shanghai"
},
headers: {
Referer: referer
}
});
logger.info(`今日${receive_quota}积分收取成功`);
return receive_quota;
}
/**
* 请求jimeng
*
* @param method 请求方法
* @param uri 请求路径
* @param params 请求参数
* @param headers 请求头
*/
export async function request(
method: string,
uri: string,
refreshToken: string,
options: AxiosRequestConfig & { noDefaultParams?: boolean } = {}
) {
const { token: tokenWithRegion, proxyUrl } = parseProxyFromToken(refreshToken);
const regionInfo = parseRegionFromToken(tokenWithRegion);
const { isUS, isHK, isJP, isSG } = regionInfo;
await acquireToken(regionInfo.isInternational ? tokenWithRegion.substring(3) : tokenWithRegion);
const deviceTime = util.unixTimestamp();
const sign = util.md5(
`9e2c|${uri.slice(-7)}|${PLATFORM_CODE}|${VERSION_CODE}|${deviceTime}||11ac`
);
let baseUrl: string;
let aid: number;
let region: string;
if (isUS) {
if (uri.startsWith("/commerce/")) {
baseUrl = BASE_URL_US_COMMERCE;
} else {
baseUrl = BASE_URL_DREAMINA_US;
}
aid = DEFAULT_ASSISTANT_ID_US;
region = REGION_US;
} else if (isHK || isJP || isSG) {
// HK, JP and SG regions use the same SG base URL
if (uri.startsWith("/commerce/")) {
baseUrl = BASE_URL_HK_COMMERCE;
} else {
baseUrl = BASE_URL_DREAMINA_HK;
}
if (isJP) {
aid = DEFAULT_ASSISTANT_ID_JP;
region = REGION_JP;
} else if (isSG) {
aid = DEFAULT_ASSISTANT_ID_SG;
region = REGION_SG;
} else {
aid = DEFAULT_ASSISTANT_ID_HK;
region = REGION_HK;
}
} else {
// CN region
baseUrl = BASE_URL_CN;
aid = DEFAULT_ASSISTANT_ID_CN;
region = REGION_CN;
}
const origin = new URL(baseUrl).origin;
const fullUrl = `${baseUrl}${uri}`;
const requestParams = options.noDefaultParams ? (options.params || {}) : {
aid: aid,
device_platform: "web",
region: region,
...(isUS || isHK || isJP || isSG ? {} : { webId: WEB_ID }),
da_version: DA_VERSION,
os: "windows",
web_component_open_flag: 1,
web_version: WEB_VERSION,
aigc_features: "app_lip_sync",
...(options.params || {}),
};
const headers = {
...FAKE_HEADERS,
Origin: origin,
Referer: origin,
"App-Sdk-Version": "48.0.0",
Appid: aid,
Cookie: generateCookie(tokenWithRegion),
"Device-Time": deviceTime,
Lan: isUS ? "en" : isJP ? "ja" : (isHK || isSG) ? "en" : "zh-Hans",
Loc: isUS ? "us" : isJP ? "jp" : isHK ? "hk" : isSG ? "sg" : "cn",
Sign: sign,
"Sign-Ver": "1",
Tdid: "",
...(options.headers || {}),
};
logger.info(`发送请求: ${method.toUpperCase()} ${fullUrl}`);
if (proxyUrl) {
const maskedProxyUrl = proxyUrl.replace(/\/\/([^@/]+)@/i, "//***@");
logger.info(`使用代理: ${maskedProxyUrl}`);
}
logger.info(`请求参数: ${JSON.stringify(requestParams)}`);
logger.info(`请求数据: ${JSON.stringify(options.data || {})}`);
const proxyAgent = proxyUrl
? (proxyUrl.toLowerCase().startsWith("socks")
? new SocksProxyAgent(proxyUrl)
: new HttpsProxyAgent(proxyUrl))
: undefined;
// 添加重试逻辑
let retries = 0;
const maxRetries = RETRY_CONFIG.MAX_RETRY_COUNT;
let lastError = null;
while (retries <= maxRetries) {
try {
if (retries > 0) {
logger.info(`第 ${retries} 次重试请求: ${method.toUpperCase()} ${fullUrl}`);
// 重试前等待一段时间
await new Promise(resolve => setTimeout(resolve, RETRY_CONFIG.RETRY_DELAY));
}
const response = await axios.request({
method,
url: fullUrl,
params: requestParams,
headers: headers,
timeout: 45000, // 增加超时时间到45秒
validateStatus: () => true, // 允许任何状态码
..._.omit(options, "params", "headers", "noDefaultParams"),
...(proxyAgent ? { httpAgent: proxyAgent, httpsAgent: proxyAgent, proxy: false } : {}),
});
// 记录响应状态和头信息
logger.info(`响应状态: ${response.status} ${response.statusText}`);
// 流式响应直接返回response
if (options.responseType == "stream") return response;
// 记录响应数据摘要
const responseDataSummary = JSON.stringify(response.data).substring(0, 500) +
(JSON.stringify(response.data).length > 500 ? "..." : "");
//const responseDataSummary = JSON.stringify(response.data)
logger.info(`响应数据摘要: ${responseDataSummary}`);
// 检查HTTP状态码
if (response.status >= 400) {
logger.warn(`HTTP错误: ${response.status} ${response.statusText}`);
if (retries < maxRetries) {
retries++;
continue;
}
}
return checkResult(response);
}
catch (error) {
lastError = error;
logger.error(`请求失败 (尝试 ${retries + 1}/${maxRetries + 1}): ${error.message}`);
// 如果是网络错误或超时,尝试重试
// 包含常见的网络错误:ECONNRESET(连接重置)、ENOTFOUND(DNS解析失败)、
// ECONNREFUSED(连接被拒绝)、EAI_AGAIN(DNS临时失败)、EPIPE(管道破裂)
const retryableErrorCodes = [
'ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND',
'ECONNREFUSED', 'EAI_AGAIN', 'EPIPE', 'ENETUNREACH', 'EHOSTUNREACH'
];
const isRetryableError = retryableErrorCodes.includes(error.code) ||
error.message.includes('timeout') ||
error.message.includes('network') ||
error.message.includes('ECONNRESET') ||
error.message.includes('socket hang up') ||
error.message.includes('Proxy connection');
if (isRetryableError && retries < maxRetries) {
retries++;
continue;
}
// 其他错误直接抛出
break;
}
}
// 所有重试都失败了,抛出最后一个错误
if (lastError) {
logger.error(`请求失败,已重试 ${retries} 次: ${lastError.message}`);
if (lastError.response) {
logger.error(`响应状态: ${lastError.response.status}`);
logger.error(`响应数据: ${JSON.stringify(lastError.response.data)}`);
}
throw lastError;
} else {
// 这种情况理论上不应该发生,但为了安全起见
const error = new Error(`请求失败,已重试 ${retries} 次,但没有具体错误信息`);
logger.error(error.message);
throw error;
}
}
/**
* 检测上传图片内容合规性(仅国内站)
* 调用 algo_proxy 接口进行图片安全检测,不通过则抛出异常
*
* @param imageUri 已上传图片的 URI
* @param refreshToken 刷新令牌
* @param regionInfo 区域信息
*/
export async function checkImageContent(
imageUri: string,
refreshToken: string,
regionInfo: RegionInfo
): Promise<void> {
// 仅国内站需要内容检测
if (regionInfo.isInternational) return;
const babiParam = JSON.stringify({
scenario: "image_video_generation",
feature_key: "aigc_to_image",
feature_entrance: "to-generate",
feature_entrance_detail: "to-generate-algo_proxy",
});
logger.info(`开始图片内容安全检测: ${imageUri}`);
try {
await request("post", "/mweb/v1/algo_proxy", refreshToken, {
params: {
babi_param: babiParam,
},
data: {
scene: "image_face_ip",
options: { ip_check: true },
req_key: "benchmark_test_user_upload_image_input",
file_list: [{ file_uri: imageUri }],
req_params: {},
},
});
logger.info(`图片内容安全检测通过: ${imageUri}`);
} catch (error: any) {
// 区分内容违规(ret=2003等) vs 网络/服务异常
const isContentViolation = error.message && (
error.message.includes('2003') ||
error.message.includes('risk not pass') ||
error.message.includes('detected risk')
);
if (isContentViolation) {
logger.error(`图片内容安全检测未通过: ${imageUri}, ${error.message}`);
throw new APIException(
EX.API_REQUEST_FAILED,
`图片内容检测未通过,该图片可能包含违规内容`
);
}
// 网络/服务异常不阻塞,仅记录警告
logger.warn(`图片内容安全检测服务异常(不阻塞): ${imageUri}, ${error.message}`);
}
}
/**
* 预检查文件URL有效性
*
* @param fileUrl 文件URL
*/
export async function checkFileUrl(fileUrl: string) {
if (util.isBASE64Data(fileUrl)) return;
const result = await axios.head(fileUrl, {
timeout: 15000,
validateStatus: () => true,
});
if (result.status >= 400)
throw new APIException(
EX.API_FILE_URL_INVALID,
`File ${fileUrl} is not valid: [${result.status}] ${result.statusText}`
);
// 检查文件大小
if (result.headers && result.headers["content-length"]) {
const fileSize = parseInt(result.headers["content-length"], 10);
if (fileSize > FILE_MAX_SIZE)
throw new APIException(
EX.API_FILE_EXECEEDS_SIZE,
`File ${fileUrl} is not valid`
);
}
}
/**
* 上传文件
*
* @param refreshToken 用于刷新access_token的refresh_token
* @param fileUrl 文件URL或BASE64数据
* @param isVideoImage 是否是用于视频图像
* @returns 上传结果,包含image_uri
*/
export async function uploadFile(
refreshToken: string,
fileUrl: string,
isVideoImage: boolean = false
) {
try {
logger.info(`开始上传文件: ${fileUrl}, 视频图像模式: ${isVideoImage}`);
// 预检查远程文件URL可用性
await checkFileUrl(fileUrl);
let filename, fileData, mimeType;
// 如果是BASE64数据则直接转换为Buffer
if (util.isBASE64Data(fileUrl)) {
mimeType = util.extractBASE64DataFormat(fileUrl);
const ext = mime.getExtension(mimeType);
filename = `${util.uuid()}.${ext}`;
fileData = Buffer.from(util.removeBASE64DataHeader(fileUrl), "base64");
logger.info(`处理BASE64数据,文件名: ${filename}, 类型: ${mimeType}, 大小: ${fileData.length}字节`);
}
// 下载文件到内存,如果您的服务器内存很小,建议考虑改造为流直传到下一个接口上,避免停留占用内存
else {
filename = path.basename(fileUrl);
logger.info(`开始下载远程文件: ${fileUrl}`);
({ data: fileData } = await axios.get(fileUrl, {
responseType: "arraybuffer",
// 100M限制
maxContentLength: FILE_MAX_SIZE,
// 60秒超时
timeout: 60000,
}));
logger.info(`文件下载完成,文件名: ${filename}, 大小: ${fileData.length}字节`);
}
// 获取文件的MIME类型
mimeType = mimeType || mime.getType(filename);
logger.info(`文件MIME类型: ${mimeType}`);
// 构建FormData
const formData = new FormData();
const blob = new Blob([fileData], { type: mimeType });
formData.append('file', blob, filename);
// 获取上传凭证
logger.info(`请求上传凭证,场景: ${isVideoImage ? 'video_cover' : 'aigc_image'}`);
const uploadProofUrl = 'https://imagex.bytedanceapi.com/';
const proofResult = await request(
'POST',
'/mweb/v1/get_upload_image_proof',
refreshToken,
{
data: {
scene: isVideoImage ? 'video_cover' : 'aigc_image',
file_name: filename,
file_size: fileData.length,
}
}
);
if (!proofResult || !proofResult.proof_info) {
logger.error(`获取上传凭证失败: ${JSON.stringify(proofResult)}`);
throw new APIException(EX.API_REQUEST_FAILED, '获取上传凭证失败');
}
logger.info(`获取上传凭证成功`);
// 上传文件
const { proof_info } = proofResult;
logger.info(`开始上传文件到: ${uploadProofUrl}`);
const uploadResult = await axios.post(
uploadProofUrl,
formData,
{
headers: {
...proof_info.headers,
'Content-Type': 'multipart/form-data',
},
params: proof_info.query_params,
timeout: 60000,
validateStatus: () => true, // 允许任何状态码以便详细处理
}
);
logger.info(`上传响应状态: ${uploadResult.status}`);
if (!uploadResult || uploadResult.status !== 200) {
logger.error(`上传文件失败: 状态码 ${uploadResult?.status}, 响应: ${JSON.stringify(uploadResult?.data)}`);
throw new APIException(EX.API_REQUEST_FAILED, `上传文件失败: 状态码 ${uploadResult?.status}`);
}
// 验证 proof_info.image_uri 是否存在
if (!proof_info.image_uri) {
logger.error(`上传凭证中缺少 image_uri: ${JSON.stringify(proof_info)}`);
throw new APIException(EX.API_REQUEST_FAILED, '上传凭证中缺少 image_uri');
}
logger.info(`文件上传成功: ${proof_info.image_uri}`);
// 返回上传结果
return {
image_uri: proof_info.image_uri,
uri: proof_info.image_uri,
}
} catch (error) {
logger.error(`文件上传过程中发生错误: ${error.message}`);
throw error;
}
}
/**
* 检查请求结果
*
* @param result 结果
*/
export function checkResult(result: AxiosResponse) {
const { ret, errmsg, data } = result.data;
if (!_.isFinite(Number(ret))) return result.data;
if (ret === '0') return data;
// 使用统一错误处理器
JimengErrorHandler.handleApiResponse(result.data as JimengErrorResponse, {
context: '即梦API请求',
operation: '请求'
});
}
/**
* Token切分
*
* @param authorization 认证字符串
*/
export function tokenSplit(authorization: string) {
return authorization.replace("Bearer ", "").split(",");
}
/**
* 获取Token存活状态
*/
export async function getTokenLiveStatus(refreshToken: string) {
try {
const result = await request(
"POST",
"/passport/account/info/v2",
refreshToken,
{
params: {
account_sdk_source: "web",
},
}
);
// request 内部已调用 checkResult,直接使用返回值
return !!result?.user_id;
} catch (err) {
return false;
}
}