Spaces:
Sleeping
Sleeping
| import { PassThrough } from "stream"; | |
| import path from "path"; | |
| import _ from "lodash"; | |
| import mime from "mime"; | |
| import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; | |
| import APIException from "@/lib/exceptions/APIException.ts"; | |
| import EX from "@/api/consts/exceptions.ts"; | |
| import { createParser } from "eventsource-parser"; | |
| import logger from "@/lib/logger.ts"; | |
| import util from "@/lib/util.ts"; | |
| // 模型名称 | |
| const MODEL_NAME = "jimeng"; | |
| // 默认的AgentID | |
| const DEFAULT_ASSISTANT_ID = 513695; | |
| // 版本号 | |
| const VERSION_CODE = "5.8.0"; | |
| // 平台代码 | |
| const PLATFORM_CODE = "7"; | |
| // 设备ID | |
| const DEVICE_ID = Math.random() * 999999999999999999 + 7000000000000000000; | |
| // WebID | |
| const WEB_ID = Math.random() * 999999999999999999 + 7000000000000000000; | |
| // 用户ID | |
| const USER_ID = util.uuid(false); | |
| // 最大重试次数 | |
| const MAX_RETRY_COUNT = 3; | |
| // 重试延迟 | |
| const RETRY_DELAY = 5000; | |
| // 伪装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", | |
| Appid: DEFAULT_ASSISTANT_ID, | |
| Appvr: VERSION_CODE, | |
| Origin: "https://jimeng.jianying.com", | |
| Pragma: "no-cache", | |
| Priority: "u=1, i", | |
| Referer: "https://jimeng.jianying.com", | |
| 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; | |
| } | |
| /** | |
| * 生成cookie | |
| */ | |
| export function generateCookie(refreshToken: string) { | |
| return [ | |
| `_tea_web_id=${WEB_ID}`, | |
| `is_staff_user=false`, | |
| `store-region=cn-gd`, | |
| `store-region-src=uid`, | |
| `sid_guard=${refreshToken}%7C${util.unixTimestamp()}%7C5184000%7CMon%2C+03-Feb-2025+08%3A17%3A09+GMT`, | |
| `uid_tt=${USER_ID}`, | |
| `uid_tt_ss=${USER_ID}`, | |
| `sid_tt=${refreshToken}`, | |
| `sessionid=${refreshToken}`, | |
| `sessionid_ss=${refreshToken}`, | |
| `sid_tt=${refreshToken}` | |
| ].join("; "); | |
| } | |
| /** | |
| * 获取积分信息 | |
| * | |
| * @param refreshToken 用于刷新access_token的refresh_token | |
| */ | |
| export async function getCredit(refreshToken: string) { | |
| const { | |
| credit: { gift_credit, purchase_credit, vip_credit } | |
| } = await request("POST", "/commerce/v1/benefits/user_credit", refreshToken, { | |
| data: {}, | |
| headers: { | |
| // Cookie: 'x-web-secsdk-uid=ef44bd0d-0cf6-448c-b517-fd1b5a7267ba; s_v_web_id=verify_m4b1lhlu_DI8qKRlD_7mJJ_4eqx_9shQ_s8eS2QLAbc4n; passport_csrf_token=86f3619c0c4a9c13f24117f71dc18524; passport_csrf_token_default=86f3619c0c4a9c13f24117f71dc18524; n_mh=9-mIeuD4wZnlYrrOvfzG3MuT6aQmCUtmr8FxV8Kl8xY; sid_guard=a7eb745aec44bb3186dbc2083ea9e1a6%7C1733386629%7C5184000%7CMon%2C+03-Feb-2025+08%3A17%3A09+GMT; uid_tt=59a46c7d3f34bda9588b93590cca2e12; uid_tt_ss=59a46c7d3f34bda9588b93590cca2e12; sid_tt=a7eb745aec44bb3186dbc2083ea9e1a6; sessionid=a7eb745aec44bb3186dbc2083ea9e1a6; sessionid_ss=a7eb745aec44bb3186dbc2083ea9e1a6; is_staff_user=false; sid_ucp_v1=1.0.0-KGRiOGY2ODQyNWU1OTk3NzRhYTE2ZmZhYmFjNjdmYjY3NzRmZGRiZTgKHgjToPCw0cwbEIXDxboGGJ-tHyAMMITDxboGOAhAJhoCaGwiIGE3ZWI3NDVhZWM0NGJiMzE4NmRiYzIwODNlYTllMWE2; ssid_ucp_v1=1.0.0-KGRiOGY2ODQyNWU1OTk3NzRhYTE2ZmZhYmFjNjdmYjY3NzRmZGRiZTgKHgjToPCw0cwbEIXDxboGGJ-tHyAMMITDxboGOAhAJhoCaGwiIGE3ZWI3NDVhZWM0NGJiMzE4NmRiYzIwODNlYTllMWE2; store-region=cn-gd; store-region-src=uid; user_spaces_idc={"7444764277623653426":"lf"}; ttwid=1|cxHJViEev1mfkjntdMziir8SwbU8uPNVSaeh9QpEUs8|1733966961|d8d52f5f56607427691be4ac44253f7870a34d25dd05a01b4d89b8a7c5ea82ad; _tea_web_id=7444838473275573797; fpk1=fa6c6a4d9ba074b90003896f36b6960066521c1faec6a60bdcb69ec8ddf85e8360b4c0704412848ec582b2abca73d57a; odin_tt=efe9dc150207879b88509e651a1c4af4e7ffb4cfcb522425a75bd72fbf894eda570bbf7ffb551c8b1de0aa2bfa0bd1be6c4157411ecdcf4464fcaf8dd6657d66', | |
| Referer: "https://jimeng.jianying.com/ai-tool/image/generate", | |
| // "Device-Time": 1733966964, | |
| // Sign: "f3dbb824b378abea7c03cbb152b3a365" | |
| } | |
| }); | |
| 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 | |
| } | |
| } | |
| /** | |
| * 接收今日积分 | |
| * | |
| * @param refreshToken 用于刷新access_token的refresh_token | |
| */ | |
| export async function receiveCredit(refreshToken: string) { | |
| logger.info("正在收取今日积分...") | |
| const { cur_total_credits, receive_quota } = await request("POST", "/commerce/v1/benefits/credit_receive", refreshToken, { | |
| data: { | |
| time_zone: "Asia/Shanghai" | |
| }, | |
| headers: { | |
| Referer: "https://jimeng.jianying.com/ai-tool/image/generate" | |
| } | |
| }); | |
| logger.info(`\n今日${receive_quota}积分收取成功\n剩余积分: ${cur_total_credits}`); | |
| return cur_total_credits; | |
| } | |
| /** | |
| * 请求jimeng | |
| * | |
| * @param method 请求方法 | |
| * @param uri 请求路径 | |
| * @param params 请求参数 | |
| * @param headers 请求头 | |
| */ | |
| export async function request( | |
| method: string, | |
| uri: string, | |
| refreshToken: string, | |
| options: AxiosRequestConfig = {} | |
| ) { | |
| const token = await acquireToken(refreshToken); | |
| const deviceTime = util.unixTimestamp(); | |
| const sign = util.md5( | |
| `9e2c|${uri.slice(-7)}|${PLATFORM_CODE}|${VERSION_CODE}|${deviceTime}||11ac` | |
| ); | |
| const fullUrl = `https://jimeng.jianying.com${uri}`; | |
| const requestParams = { | |
| aid: DEFAULT_ASSISTANT_ID, | |
| device_platform: "web", | |
| region: "CN", | |
| webId: WEB_ID, | |
| da_version: "3.3.2", | |
| web_component_open_flag: 1, | |
| web_version: "7.5.0", | |
| aigc_features: "app_lip_sync", | |
| ...(options.params || {}), | |
| }; | |
| const headers = { | |
| ...FAKE_HEADERS, | |
| Cookie: generateCookie(token), | |
| "Device-Time": deviceTime, | |
| Sign: sign, | |
| "Sign-Ver": "1", | |
| ...(options.headers || {}), | |
| }; | |
| logger.info(`发送请求: ${method.toUpperCase()} ${fullUrl}`); | |
| logger.info(`请求参数: ${JSON.stringify(requestParams)}`); | |
| logger.info(`请求数据: ${JSON.stringify(options.data || {})}`); | |
| // 添加重试逻辑 | |
| let retries = 0; | |
| const maxRetries = 3; // 最大重试次数 | |
| let lastError = null; | |
| while (retries <= maxRetries) { | |
| try { | |
| if (retries > 0) { | |
| logger.info(`第 ${retries} 次重试请求: ${method.toUpperCase()} ${fullUrl}`); | |
| // 重试前等待一段时间 | |
| await new Promise(resolve => setTimeout(resolve, 1000 * retries)); | |
| } | |
| const response = await axios.request({ | |
| method, | |
| url: fullUrl, | |
| params: requestParams, | |
| headers: headers, | |
| timeout: 45000, // 增加超时时间到45秒 | |
| validateStatus: () => true, // 允许任何状态码 | |
| ..._.omit(options, "params", "headers"), | |
| }); | |
| // 记录响应状态和头信息 | |
| 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 ? "..." : ""); | |
| 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}`); | |
| // 如果是网络错误或超时,尝试重试 | |
| if ((error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT' || | |
| error.message.includes('timeout') || error.message.includes('network')) && | |
| retries < maxRetries) { | |
| retries++; | |
| continue; | |
| } | |
| // 其他错误直接抛出 | |
| break; | |
| } | |
| } | |
| // 所有重试都失败了,抛出最后一个错误 | |
| logger.error(`请求失败,已重试 ${retries} 次: ${lastError.message}`); | |
| if (lastError.response) { | |
| logger.error(`响应状态: ${lastError.response.status}`); | |
| logger.error(`响应数据: ${JSON.stringify(lastError.response.data)}`); | |
| } | |
| throw lastError; | |
| } | |
| /** | |
| * 预检查文件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; | |
| if (ret === '5000') | |
| throw new APIException(EX.API_IMAGE_GENERATION_INSUFFICIENT_POINTS, `[无法生成图像]: 即梦积分可能不足,${errmsg}`); | |
| throw new APIException(EX.API_REQUEST_FAILED, `[请求jimeng失败]: ${errmsg}`); | |
| } | |
| /** | |
| * Token切分 | |
| * | |
| * @param authorization 认证字符串 | |
| */ | |
| export function tokenSplit(authorization: string) { | |
| return authorization.replace("Bearer ", "").split(","); | |
| } | |
| /** | |
| * 获取Token存活状态 | |
| */ | |
| export async function getTokenLiveStatus(refreshToken: string) { | |
| const result = await request( | |
| "POST", | |
| "/passport/account/info/v2", | |
| refreshToken, | |
| { | |
| params: { | |
| account_sdk_source: "web", | |
| }, | |
| } | |
| ); | |
| try { | |
| const { user_id } = checkResult(result); | |
| return !!user_id; | |
| } catch (err) { | |
| return false; | |
| } | |
| } |