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 { 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 { // 仅国内站需要内容检测 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; } }