Files
MoonTVPlus/src/lib/netdisk/mobile.client.ts
T
2026-04-27 16:01:50 +08:00

340 lines
10 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
export interface MobileShareVideoFile {
name: string;
contentId: string;
linkID: string;
size: number;
}
export interface MobileShareListResult {
title: string;
files: MobileShareVideoFile[];
}
function ensureHeaderSafeAuthorization(authorization: string): string {
const raw = authorization.trim();
const normalized = /^basic\s+/i.test(raw) ? raw.replace(/^basic\s+/i, 'Basic ') : `Basic ${raw}`;
for (let i = 0; i < normalized.length; i += 1) {
if (normalized.charCodeAt(i) > 255) {
throw new Error('移动云盘 Authorization 含有非法字符,请检查是否包含中文标点或说明文字');
}
}
return normalized;
}
export function normalizeMobileAuthorization(authorization: string): string {
return ensureHeaderSafeAuthorization(authorization);
}
export function assertMobileAuthorizationHeaderSafe(authorization: string): string {
return ensureHeaderSafeAuthorization(authorization);
}
const SHARE_ID_PATTERNS = [
/https:\/\/yun\.139\.com\/shareweb\/#\/w\/i\/([^&/]+)/,
/https:\/\/yun\.139\.com\/sharewap\/#\/m\/i\?([^&/]+)/,
/https:\/\/caiyun\.139\.com\/m\/i\?([^&/]+)/,
/https:\/\/caiyun\.139\.com\/w\/i\/([^&/]+)/,
];
const BASE_URL = 'https://share-kd-njs.yun.139.com/yun-share/richlifeApp/devapp/IOutLink/';
const ALTERNATIVE_URLS = [
'https://cloud.139.com/yun-share/richlifeApp/devapp/IOutLink/',
'https://yun.139.com/yun-share/richlifeApp/devapp/IOutLink/',
'https://share.yun.139.com/yun-share/richlifeApp/devapp/IOutLink/',
];
const AES_KEY = Buffer.from('PVGDwmcvfs1uV3d1', 'utf8');
const BASE_HEADERS: HeadersInit = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'hcy-cool-flag': '1',
'x-deviceinfo': '||3|12.27.0|chrome|136.0.0.0|189f4426ca008b9cbe9bf9bd79723d77||windows 10|1536X695|zh-CN|||',
Origin: 'https://yun.139.com',
Referer: 'https://yun.139.com/',
};
function normalizeBase64(input: string): string {
return input.replace(/-/g, '+').replace(/_/g, '/');
}
function extractAccountFromAuthorization(authorization?: string): string {
if (!authorization) return '';
try {
const normalized = ensureHeaderSafeAuthorization(authorization);
const matched = normalized.match(/^Basic\s+(.+)$/i);
if (!matched?.[1]) return '';
const decoded = Buffer.from(matched[1].trim(), 'base64').toString('utf8');
const parts = decoded.split(':');
return parts[1]?.trim() || '';
} catch {
return '';
}
}
function encryptPayload(data: string | object): string {
const iv = randomBytes(16);
const cipher = createCipheriv('aes-128-cbc', AES_KEY, iv);
const plain = typeof data === 'string' ? data : JSON.stringify(data);
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
return Buffer.concat([iv, encrypted]).toString('base64');
}
function decryptPayload(data: string): string {
const payload = Buffer.from(normalizeBase64(data), 'base64');
const iv = payload.subarray(0, 16);
const encrypted = payload.subarray(16);
const decipher = createDecipheriv('aes-128-cbc', AES_KEY, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decrypted.toString('utf8');
}
function parseShareId(url: string): string {
for (const pattern of SHARE_ID_PATTERNS) {
const matched = pattern.exec(url);
if (matched?.[1]) return matched[1];
}
throw new Error('无法解析移动云盘分享链接');
}
async function postPlain(
url: string,
body: string | object,
encrypted = false,
authorization?: string
): Promise<string> {
const response = await fetch(url, {
method: 'POST',
headers: {
...BASE_HEADERS,
...(authorization ? { authorization: ensureHeaderSafeAuthorization(authorization) } : {}),
},
body: encrypted ? encryptPayload(body) : JSON.stringify(body),
cache: 'no-store',
});
const text = await response.text();
if (!response.ok) {
throw new Error(`移动云盘接口请求失败 (${response.status})`);
}
return text;
}
async function fetchShareInfo(linkId: string, pCaID: string, authorization?: string) {
const requestPayload = {
getOutLinkInfoReq: {
account: '',
linkID: linkId,
passwd: '',
caSrt: 1,
coSrt: 1,
srtDr: 0,
bNum: 1,
pCaID,
eNum: 200,
},
commonAccountInfo: { account: '', accountType: 1 },
};
let lastError: unknown;
for (const baseUrl of [BASE_URL, ...ALTERNATIVE_URLS]) {
try {
const raw = await postPlain(`${baseUrl}getOutLinkInfoV6`, requestPayload, true, authorization);
if (!raw || raw === 'null') {
continue;
}
const decrypted = decryptPayload(raw);
const parsed = JSON.parse(decrypted);
return parsed?.data ?? null;
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error ? lastError : new Error('获取移动云盘分享信息失败');
}
async function collectFiles(linkId: string, path = 'root', authorization?: string): Promise<MobileShareVideoFile[]> {
const info = await fetchShareInfo(linkId, path, authorization);
if (!info) return [];
const currentFiles = Array.isArray(info.coLst)
? info.coLst
.filter((item: any) => item && item.coType === 3)
.map((item: any) => ({
name: String(item.coName || '未命名视频'),
contentId: String(item.path || ''),
linkID: linkId,
size: Number(item.coSize || 0),
}))
.filter((item: MobileShareVideoFile) => item.contentId)
: [];
const childDirs = Array.isArray(info.caLst)
? info.caLst
.map((item: any) => String(item?.path || ''))
.filter(Boolean)
: [];
if (childDirs.length === 0) {
return currentFiles;
}
const nested = await Promise.all(
childDirs.map((childPath: string) => collectFiles(linkId, childPath, authorization))
);
return [...currentFiles, ...nested.flat()];
}
function sortFiles(files: MobileShareVideoFile[]): MobileShareVideoFile[] {
return [...files].sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }));
}
export async function listMobileShareVideos(shareUrl: string, authorization?: string): Promise<MobileShareListResult> {
const linkId = parseShareId(shareUrl);
const files = sortFiles(await collectFiles(linkId, 'root', authorization));
if (files.length === 0) {
throw new Error('移动云盘分享中没有视频文件');
}
return {
title: files.length === 1 ? files[0].name.replace(/\.[^.]+$/, '') : '移动云盘立即播放',
files,
};
}
export async function getMobileSharePlayUrl(contentId: string, linkID: string, authorization?: string): Promise<string> {
const requestPayload = {
getContentInfoFromOutLinkReq: {
contentId: contentId.split('/')[1] || contentId,
linkID,
account: '',
},
commonAccountInfo: {
account: '',
accountType: 1,
},
};
let lastErrorMessage = '未获取到移动云盘播放地址';
try {
const response = await fetch(`${BASE_URL}getContentInfoFromOutLink`, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
Accept: 'application/json, text/plain, */*',
'Accept-Encoding': 'gzip, deflate, br, zstd',
'Content-Type': 'application/json',
...(authorization ? { authorization: ensureHeaderSafeAuthorization(authorization) } : {}),
},
body: JSON.stringify(requestPayload),
cache: 'no-store',
});
const raw = await response.text();
if (!response.ok) {
throw new Error(`移动云盘播放接口请求失败 (${response.status})`);
}
let parsed: any;
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
throw new Error('移动云盘播放接口返回异常');
}
const playUrl =
parsed?.data?.contentInfo?.presentURL ||
parsed?.data?.contentInfo?.presentUrl ||
parsed?.data?.contentInfo?.playUrl ||
parsed?.data?.contentInfo?.url ||
parsed?.contentInfo?.presentURL ||
parsed?.contentInfo?.presentUrl ||
parsed?.data?.presentURL ||
parsed?.data?.url;
if (playUrl) {
return playUrl;
}
lastErrorMessage =
parsed?.message ||
parsed?.msg ||
parsed?.data?.message ||
parsed?.data?.msg ||
'未获取到移动云盘播放地址';
} catch (error) {
lastErrorMessage =
error instanceof Error ? error.message : '未获取到移动云盘播放地址';
}
throw new Error(lastErrorMessage);
}
export async function getMobileShareDownloadUrl(
contentId: string,
linkID: string,
authorization?: string
): Promise<string> {
if (!authorization) {
throw new Error('移动云盘未配置验证头');
}
const account = extractAccountFromAuthorization(authorization);
if (!account) {
throw new Error('无法从移动云盘验证头中解析账号');
}
const requestPayload = {
dlFromOutLinkReqV3: {
linkID,
account,
coIDLst: {
item: [contentId],
},
},
commonAccountInfo: {
account,
accountType: 1,
},
};
const raw = await postPlain(
`${BASE_URL}dlFromOutLinkV3`,
requestPayload,
true,
authorization
);
let parsed: any;
try {
const decrypted = decryptPayload(raw);
parsed = JSON.parse(decrypted);
} catch {
throw new Error('移动云盘下载接口返回异常');
}
const downloadUrl =
parsed?.data?.redrUrl ||
parsed?.data?.downloadUrl ||
parsed?.data?.url;
if (!downloadUrl) {
throw new Error(
parsed?.message ||
parsed?.msg ||
parsed?.data?.message ||
parsed?.data?.msg ||
'未获取到移动云盘下载地址'
);
}
return downloadUrl;
}