Download src/api/controllers/core.ts from wuran/hailuo: direct link, hf CLI and curl.
- Browser
- Download file 12.6 kB
-
https://huggingface.co/spaces/wuran/hailuo/resolve/main/src/api/controllers/core.ts
- Command line
-
hf download hf://spaces/wuran/hailuo/src/api/controllers/core.ts
-
curl -L -o core.ts https://huggingface.co/spaces/wuran/hailuo/resolve/main/src/api/controllers/core.ts
12.6 kB
| import http2, { ClientHttp2Session } from "http2"; | |
| import path from "path"; | |
| import fs from "fs"; | |
| import _ from "lodash"; | |
| import mime from "mime"; | |
| import FormData from "form-data"; | |
| import OSS from "ali-oss"; | |
| import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; | |
| 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"; | |
| // 设备信息有效期 | |
| const DEVICE_INFO_EXPIRES = 10800; | |
| // 伪装headers | |
| const FAKE_HEADERS = { | |
| Accept: "*/*", | |
| "Accept-Encoding": "gzip, deflate, br, zstd", | |
| "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", | |
| "Cache-Control": "no-cache", | |
| Origin: "https://hailuoai.com", | |
| Pragma: "no-cache", | |
| Priority: "u=1, i", | |
| "Sec-Ch-Ua": | |
| '"Not)A;Brand";v="99", "Google Chrome";v="127", "Chromium";v="127"', | |
| "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/127.0.0.0 Safari/537.36", | |
| }; | |
| // 伪装数据 | |
| const FAKE_USER_DATA = { | |
| device_platform: "web", | |
| app_id: "3001", | |
| version_code: "22200", | |
| uuid: null, | |
| device_id: null, | |
| os_name: "Windows", | |
| browser_name: "chrome", | |
| device_memory: 8, | |
| cpu_core_num: 12, | |
| browser_language: "zh-CN", | |
| browser_platform: "Win32", | |
| screen_width: 1920, | |
| screen_height: 1080, | |
| unix: null, | |
| }; | |
| const SENTRY_RELEASE = "CI7N-1MjJnx5pru-bzzhR"; | |
| const SENTRY_PUBLIC_KEY = "6cf106db5c7b7262eae7cc6b411c776a"; | |
| // 文件最大大小 | |
| const FILE_MAX_SIZE = 100 * 1024 * 1024; | |
| // 设备信息映射 | |
| const deviceInfoMap = new Map(); | |
| // 设备信息请求队列映射 | |
| const deviceInfoRequestQueueMap: Record<string, Function[]> = {}; | |
| /** | |
| * 请求设备信息 | |
| * | |
| * @param token 认证token | |
| */ | |
| async function requestDeviceInfo(token: string) { | |
| if (deviceInfoRequestQueueMap[token]) | |
| return new Promise((resolve) => | |
| deviceInfoRequestQueueMap[token].push(resolve) | |
| ); | |
| deviceInfoRequestQueueMap[token] = []; | |
| logger.info(`Token: ${token}`); | |
| const result = await (async () => { | |
| const userId = util.uuid(); | |
| const result = await request( | |
| "POST", | |
| "/v1/api/user/device/register", | |
| { | |
| uuid: userId, | |
| }, | |
| token, | |
| { | |
| userId, | |
| }, | |
| { | |
| params: FAKE_USER_DATA | |
| } | |
| ); | |
| const { deviceIDStr } = checkResult(result); | |
| return { | |
| deviceId: deviceIDStr, | |
| userId, | |
| refreshTime: util.unixTimestamp() + DEVICE_INFO_EXPIRES, | |
| }; | |
| })() | |
| .then((result) => { | |
| if (deviceInfoRequestQueueMap[token]) { | |
| deviceInfoRequestQueueMap[token].forEach((resolve) => resolve(result)); | |
| delete deviceInfoRequestQueueMap[token]; | |
| } | |
| logger.success(`Refresh successful`); | |
| return result; | |
| }) | |
| .catch((err) => { | |
| if (deviceInfoRequestQueueMap[token]) { | |
| deviceInfoRequestQueueMap[token].forEach((resolve) => resolve(err)); | |
| delete deviceInfoRequestQueueMap[token]; | |
| } | |
| return err; | |
| }); | |
| if (_.isError(result)) throw result; | |
| return result; | |
| } | |
| /** | |
| * 获取缓存中的设备信息 | |
| * | |
| * 避免短时间大量刷新token,未加锁,如果有并发要求还需加锁 | |
| * | |
| * @param token 认证token | |
| */ | |
| async function acquireDeviceInfo(token: string): Promise<string> { | |
| let result = deviceInfoMap.get(token); | |
| if (!result) { | |
| result = await requestDeviceInfo(token); | |
| deviceInfoMap.set(token, result); | |
| } | |
| if (util.unixTimestamp() > result.refreshTime) { | |
| result = await requestDeviceInfo(token); | |
| deviceInfoMap.set(token, result); | |
| } | |
| return result; | |
| } | |
| /** | |
| * 预检查文件URL有效性 | |
| * | |
| * @param fileUrl 文件URL | |
| */ | |
| 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 fileUrl 文件URL | |
| * @param token 认证token | |
| */ | |
| async function uploadFile(fileUrl: string, token: string) { | |
| // 预检查远程文件URL可用性 | |
| await checkFileUrl(fileUrl); | |
| let filename, fileData: Buffer, 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"); | |
| } | |
| // 下载文件到内存,如果您的服务器内存很小,建议考虑改造为流直传到下一个接口上,避免停留占用内存 | |
| else { | |
| filename = `${util.uuid()}${path.extname(fileUrl)}`; | |
| ({ data: fileData } = await axios.get(fileUrl, { | |
| responseType: "arraybuffer", | |
| // 100M限制 | |
| maxContentLength: FILE_MAX_SIZE, | |
| // 60秒超时 | |
| timeout: 60000, | |
| })); | |
| } | |
| // 获取文件的MIME类型 | |
| mimeType = mimeType || mime.getType(filename); | |
| const deviceInfo = await acquireDeviceInfo(token); | |
| // 获取文件上传策略 | |
| const policyResult = await request( | |
| "GET", | |
| "/v1/api/files/request_policy", | |
| {}, | |
| token, | |
| deviceInfo | |
| ); | |
| const { | |
| accessKeyId, | |
| accessKeySecret, | |
| bucketName, | |
| dir, | |
| endpoint, | |
| securityToken, | |
| } = checkResult(policyResult); | |
| // 上传文件到OSS | |
| const client = new OSS({ | |
| accessKeyId, | |
| accessKeySecret, | |
| bucket: bucketName, | |
| endpoint, | |
| stsToken: securityToken, | |
| }); | |
| await client.put(`${dir}/${filename}`, fileData); | |
| // 上传回调 | |
| const policyCallbackResult = await request( | |
| "POST", | |
| "/v1/api/files/policy_callback", | |
| { | |
| fileName: filename, | |
| originFileName: filename, | |
| dir, | |
| endpoint: endpoint, | |
| bucketName, | |
| size: `${fileData.byteLength}`, | |
| mimeType, | |
| }, | |
| token, | |
| deviceInfo | |
| ); | |
| const { fileID } = checkResult(policyCallbackResult); | |
| const isImage = [ | |
| "image/jpeg", | |
| "image/jpg", | |
| "image/tiff", | |
| "image/png", | |
| "image/bmp", | |
| "image/gif", | |
| "image/svg+xml", | |
| "image/webp", | |
| "image/ico", | |
| "image/heic", | |
| "image/heif", | |
| "image/bmp", | |
| "image/x-icon", | |
| "image/vnd.microsoft.icon", | |
| "image/x-png", | |
| ].includes(mimeType); | |
| return { | |
| fileType: isImage ? 2 : 6, | |
| filename, | |
| fileId: fileID, | |
| }; | |
| } | |
| /** | |
| * 检查请求结果 | |
| * | |
| * @param result 结果 | |
| */ | |
| function checkResult(result: AxiosResponse) { | |
| if (!result.data) return null; | |
| const { statusInfo, data } = result.data; | |
| if (!_.isObject(statusInfo)) return result.data; | |
| const { code, message } = statusInfo as any; | |
| if (code === 0) return data; | |
| throw new APIException(EX.API_REQUEST_FAILED, `[请求hailuo失败]: ${message}`); | |
| } | |
| /** | |
| * Token切分 | |
| * | |
| * @param authorization 认证字符串 | |
| */ | |
| function tokenSplit(authorization: string) { | |
| return authorization.replace("Bearer ", "").split(","); | |
| } | |
| /** | |
| * 发起请求 | |
| * | |
| * @param method 请求方法 | |
| * @param uri 请求uri | |
| * @param data 请求数据 | |
| * @param token 认证token | |
| * @param deviceInfo 设备信息 | |
| * @param options 请求选项 | |
| */ | |
| async function request( | |
| method: string, | |
| uri: string, | |
| data: any, | |
| token: string, | |
| deviceInfo: any, | |
| options: AxiosRequestConfig = {} | |
| ) { | |
| const unix = `${Date.parse(new Date().toString())}`; | |
| const userData = _.clone(FAKE_USER_DATA); | |
| userData.uuid = deviceInfo.userId; | |
| userData.device_id = deviceInfo.deviceId || undefined; | |
| userData.unix = unix; | |
| let queryStr = ""; | |
| for (let key in userData) { | |
| if (_.isUndefined(userData[key])) continue; | |
| queryStr += `&${key}=${userData[key]}`; | |
| } | |
| queryStr = queryStr.substring(1); | |
| const dataJson = JSON.stringify(data || {}); | |
| const fullUri = `${uri}${uri.lastIndexOf("?") != -1 ? "&" : "?"}${queryStr}`; | |
| const yy = util.md5( | |
| `${encodeURIComponent(fullUri)}_${dataJson}${util.md5(unix)}ooui` | |
| ); | |
| const traceId = util.uuid(false); | |
| return await axios.request({ | |
| method, | |
| url: `https://hailuoai.com${fullUri}`, | |
| data, | |
| timeout: 15000, | |
| validateStatus: () => true, | |
| ...options, | |
| headers: { | |
| Referer: "https://hailuoai.com/", | |
| Token: token, | |
| ...FAKE_HEADERS, | |
| "Baggage": `sentry-environment=production,sentry-release=${SENTRY_RELEASE},sentry-public_key=${SENTRY_PUBLIC_KEY},sentry-trace_id=${traceId},sentry-sample_rate=1,sentry-sampled=true`, | |
| "Sentry-Trace": `${traceId}-${util.uuid(false).substring(16)}-1`, | |
| ...(options.headers || {}), | |
| Yy: yy, | |
| }, | |
| }); | |
| } | |
| /** | |
| * 发起HTTP2.0流式请求 | |
| * | |
| * @param method 请求方法 | |
| * @param uri 请求uri | |
| * @param data 请求数据 | |
| * @param token 认证token | |
| * @param deviceInfo 设备信息 | |
| * @param options 请求选项 | |
| */ | |
| async function requestStream( | |
| method: string, | |
| uri: string, | |
| data: any, | |
| token: string, | |
| deviceInfo: any, | |
| options: AxiosRequestConfig = {} | |
| ) { | |
| const unix = `${Date.parse(new Date().toString())}`; | |
| const userData = _.clone(FAKE_USER_DATA); | |
| userData.uuid = deviceInfo.userId; | |
| userData.device_id = deviceInfo.deviceId || undefined; | |
| userData.unix = unix; | |
| let queryStr = ""; | |
| for (let key in userData) { | |
| if (_.isUndefined(userData[key])) continue; | |
| queryStr += `&${key}=${userData[key]}`; | |
| } | |
| queryStr = queryStr.substring(1); | |
| const formData = new FormData(); | |
| for (let key in data) { | |
| if (!data[key]) continue; | |
| if (_.isBuffer(data[key])) { | |
| formData.append(key, data[key], { | |
| filename: "audio.mp3", | |
| contentType: "audio/mp3", | |
| }); | |
| } else formData.append(key, data[key]); | |
| } | |
| let dataJson = ""; | |
| if (data.msgContent) | |
| dataJson = `${util.md5(data.characterID)}${util.md5( | |
| data.msgContent.replace(/(\r\n|\n|\r)/g, "") | |
| )}${util.md5(data.chatID)}${util.md5(data.form ? data.form : "")}`; | |
| else if (data.voiceBytes) | |
| dataJson = `${util.md5(data.characterID)}${util.md5(data.chatID)}${util.md5( | |
| data.voiceBytes.subarray(0, 1024) | |
| )}`; | |
| data = formData; | |
| const yy = util.md5( | |
| encodeURIComponent(`${uri}?${queryStr}`) + | |
| `_${dataJson}${util.md5(unix)}ooui` | |
| ); | |
| const session: ClientHttp2Session = await new Promise((resolve, reject) => { | |
| const session = http2.connect("https://hailuoai.com"); | |
| session.on("connect", () => resolve(session)); | |
| session.on("error", reject); | |
| }); | |
| const traceId = util.uuid(false); | |
| const stream = session.request({ | |
| ":method": method, | |
| ":path": `${uri}?${queryStr}`, | |
| ":scheme": "https", | |
| Referer: "https://hailuoai.com/", | |
| Token: token, | |
| ...FAKE_HEADERS, | |
| "Baggage": `sentry-environment=production,sentry-release=${SENTRY_RELEASE},sentry-public_key=${SENTRY_PUBLIC_KEY},sentry-trace_id=${traceId},sentry-sample_rate=1,sentry-sampled=true`, | |
| "Sentry-Trace": `${traceId}-${util.uuid(false).substring(16)}-1`, | |
| ...(options.headers || {}), | |
| Yy: yy, | |
| ...data.getHeaders(), | |
| }); | |
| stream.setTimeout(120000); | |
| stream.setEncoding("utf8"); | |
| stream.end(data.getBuffer()); | |
| return { | |
| session, | |
| stream, | |
| }; | |
| } | |
| /** | |
| * 获取Token存活状态 | |
| */ | |
| async function getTokenLiveStatus(token: string) { | |
| const deviceInfo = await acquireDeviceInfo(token); | |
| const result = await request( | |
| "GET", | |
| "/v1/api/user/info", | |
| {}, | |
| token, | |
| deviceInfo | |
| ); | |
| try { | |
| const { userInfo } = checkResult(result); | |
| return _.isObject(userInfo); | |
| } catch (err) { | |
| deviceInfoMap.delete(token); | |
| return false; | |
| } | |
| } | |
| export default { | |
| acquireDeviceInfo, | |
| request, | |
| requestStream, | |
| checkResult, | |
| checkFileUrl, | |
| uploadFile, | |
| tokenSplit, | |
| getTokenLiveStatus, | |
| }; | |