2026-03-05 18:02:54 +08:00
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
2026-03-10 19:04:18 +08:00
|
|
|
|
import { Platform } from 'react-native';
|
2026-03-11 20:53:18 +08:00
|
|
|
|
import pako from 'pako';
|
2026-03-26 00:06:41 +08:00
|
|
|
|
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom, LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo, SearchSuggestItem, HotSearchItem } from './types';
|
2026-03-10 19:22:38 +08:00
|
|
|
|
import { signWbi } from '../utils/wbi';
|
2026-03-10 22:46:29 +08:00
|
|
|
|
import { parseDanmakuXml } from '../utils/danmaku';
|
2026-03-26 00:06:41 +08:00
|
|
|
|
import { getSecure } from '../utils/secureStorage';
|
2026-03-05 18:02:54 +08:00
|
|
|
|
|
2026-03-10 19:04:18 +08:00
|
|
|
|
const isWeb = Platform.OS === 'web';
|
2026-03-10 22:46:29 +08:00
|
|
|
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
2026-03-11 20:53:18 +08:00
|
|
|
|
const BASE = isWeb ? 'http://localhost:3001/bilibili-api' : 'https://api.bilibili.com';
|
2026-03-10 19:04:18 +08:00
|
|
|
|
const PASSPORT = isWeb ? 'http://localhost:3001/bilibili-passport' : 'https://passport.bilibili.com';
|
2026-03-10 22:46:29 +08:00
|
|
|
|
const COMMENT_BASE = isWeb
|
|
|
|
|
|
? 'http://localhost:3001/bilibili-comment'
|
|
|
|
|
|
: 'https://comment.bilibili.com';
|
2026-03-05 18:02:54 +08:00
|
|
|
|
|
2026-03-06 14:52:28 +08:00
|
|
|
|
function generateBuvid3(): string {
|
|
|
|
|
|
const h = () => Math.floor(Math.random() * 16).toString(16);
|
|
|
|
|
|
const s = (n: number) => Array.from({ length: n }, h).join('');
|
|
|
|
|
|
return `${s(8)}-${s(4)}-${s(4)}-${s(4)}-${s(12)}infoc`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function getBuvid3(): Promise<string> {
|
|
|
|
|
|
let buvid3 = await AsyncStorage.getItem('buvid3');
|
|
|
|
|
|
if (!buvid3) {
|
|
|
|
|
|
buvid3 = generateBuvid3();
|
|
|
|
|
|
await AsyncStorage.setItem('buvid3', buvid3);
|
|
|
|
|
|
}
|
|
|
|
|
|
return buvid3;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 18:02:54 +08:00
|
|
|
|
const api = axios.create({
|
|
|
|
|
|
baseURL: BASE,
|
|
|
|
|
|
timeout: 10000,
|
2026-03-10 19:04:18 +08:00
|
|
|
|
headers: isWeb ? {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
'Accept': 'application/json, text/plain, */*',
|
2026-03-10 19:04:18 +08:00
|
|
|
|
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
|
|
|
|
} : {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
'User-Agent': UA,
|
|
|
|
|
|
'Referer': 'https://www.bilibili.com',
|
|
|
|
|
|
'Origin': 'https://www.bilibili.com',
|
|
|
|
|
|
'Accept': 'application/json, text/plain, */*',
|
2026-03-06 14:52:28 +08:00
|
|
|
|
'Accept-Language': 'zh-CN,zh;q=0.9',
|
2026-03-05 18:02:54 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
api.interceptors.request.use(async (config) => {
|
2026-03-06 14:52:28 +08:00
|
|
|
|
const [sessdata, buvid3] = await Promise.all([
|
2026-03-26 00:06:41 +08:00
|
|
|
|
getSecure('SESSDATA'),
|
2026-03-06 14:52:28 +08:00
|
|
|
|
getBuvid3(),
|
|
|
|
|
|
]);
|
2026-03-10 19:04:18 +08:00
|
|
|
|
if (isWeb) {
|
|
|
|
|
|
// Browsers block Cookie/Referer/Origin headers; relay via custom headers to proxy
|
2026-03-11 20:53:18 +08:00
|
|
|
|
if (buvid3) config.headers['X-Buvid3'] = buvid3;
|
2026-03-10 19:04:18 +08:00
|
|
|
|
if (sessdata) config.headers['X-Sessdata'] = sessdata;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const cookies: string[] = [`buvid3=${buvid3}`];
|
|
|
|
|
|
if (sessdata) cookies.push(`SESSDATA=${sessdata}`);
|
|
|
|
|
|
config.headers['Cookie'] = cookies.join('; ');
|
|
|
|
|
|
}
|
2026-03-05 18:02:54 +08:00
|
|
|
|
return config;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-26 00:06:41 +08:00
|
|
|
|
// ─── Request deduplication ──────────────────────────────────────────────────
|
|
|
|
|
|
// Prevents identical concurrent requests (same URL + params) from hitting the network twice.
|
|
|
|
|
|
const inflightRequests = new Map<string, Promise<any>>();
|
|
|
|
|
|
|
|
|
|
|
|
function dedupeKey(url: string, params?: Record<string, any>): string {
|
|
|
|
|
|
return params ? `${url}?${JSON.stringify(params)}` : url;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Wraps an async API call so that concurrent calls with the same key share one promise. */
|
|
|
|
|
|
function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
|
|
|
|
const existing = inflightRequests.get(key);
|
|
|
|
|
|
if (existing) return existing as Promise<T>;
|
|
|
|
|
|
const promise = fn().finally(() => { inflightRequests.delete(key); });
|
|
|
|
|
|
inflightRequests.set(key, promise);
|
|
|
|
|
|
return promise;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 18:25:13 +08:00
|
|
|
|
// WBI key cache (rotates ~daily)
|
2026-03-10 19:22:38 +08:00
|
|
|
|
let wbiKeys: { imgKey: string; subKey: string } | null = null;
|
2026-03-14 18:25:13 +08:00
|
|
|
|
let wbiKeysTimestamp = 0;
|
|
|
|
|
|
const WBI_KEYS_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
2026-03-10 19:22:38 +08:00
|
|
|
|
|
|
|
|
|
|
async function getWbiKeys(): Promise<{ imgKey: string; subKey: string }> {
|
2026-03-14 18:25:13 +08:00
|
|
|
|
if (wbiKeys && Date.now() - wbiKeysTimestamp < WBI_KEYS_TTL) return wbiKeys;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await api.get('/x/web-interface/nav');
|
|
|
|
|
|
const wbiImg = res.data?.data?.wbi_img;
|
|
|
|
|
|
if (!wbiImg?.img_url || !wbiImg?.sub_url) {
|
|
|
|
|
|
if (wbiKeys) return wbiKeys; // fallback to stale cache
|
|
|
|
|
|
throw new Error('Failed to get WBI keys: missing wbi_img data');
|
|
|
|
|
|
}
|
|
|
|
|
|
const extract = (url: string) => url.split('/').pop()!.replace(/\.\w+$/, '');
|
|
|
|
|
|
wbiKeys = { imgKey: extract(wbiImg.img_url), subKey: extract(wbiImg.sub_url) };
|
|
|
|
|
|
wbiKeysTimestamp = Date.now();
|
|
|
|
|
|
return wbiKeys;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (wbiKeys) return wbiKeys; // fallback to stale cache on network error
|
|
|
|
|
|
throw e;
|
|
|
|
|
|
}
|
2026-03-10 19:22:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getRecommendFeed(freshIdx = 0): Promise<VideoItem[]> {
|
|
|
|
|
|
const { imgKey, subKey } = await getWbiKeys();
|
|
|
|
|
|
const signed = signWbi(
|
2026-03-11 20:53:18 +08:00
|
|
|
|
{ fresh_type: 3, fresh_idx: freshIdx, fresh_idx_1h: freshIdx, ps: 21, feed_version: 'V8' },
|
2026-03-10 19:22:38 +08:00
|
|
|
|
imgKey,
|
|
|
|
|
|
subKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
const res = await api.get('/x/web-interface/wbi/index/top/feed/rcmd', { params: signed });
|
|
|
|
|
|
const items: any[] = res.data.data?.item ?? [];
|
2026-03-10 20:50:44 +08:00
|
|
|
|
return items
|
2026-03-14 11:55:45 +08:00
|
|
|
|
.filter(item => item.goto === 'av' && item.bvid && item.title)
|
|
|
|
|
|
.map(item => ({
|
|
|
|
|
|
...item,
|
|
|
|
|
|
aid: item.id ?? item.aid,
|
|
|
|
|
|
pic: item.pic ?? item.cover,
|
|
|
|
|
|
owner: item.owner ?? { mid: 0, name: item.owner_info?.name ?? '', face: item.owner_info?.face ?? '' },
|
|
|
|
|
|
} as VideoItem));
|
2026-03-10 19:22:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 18:02:54 +08:00
|
|
|
|
export async function getPopularVideos(pn = 1): Promise<VideoItem[]> {
|
|
|
|
|
|
const res = await api.get('/x/web-interface/popular', { params: { pn, ps: 20 } });
|
|
|
|
|
|
return res.data.data.list as VideoItem[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 00:06:41 +08:00
|
|
|
|
export function getVideoDetail(bvid: string): Promise<VideoItem> {
|
|
|
|
|
|
return dedupe(dedupeKey('/x/web-interface/view', { bvid }), async () => {
|
|
|
|
|
|
const res = await api.get('/x/web-interface/view', { params: { bvid } });
|
|
|
|
|
|
return res.data.data as VideoItem;
|
|
|
|
|
|
});
|
2026-03-05 18:02:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 12:03:28 +08:00
|
|
|
|
export async function getVideoRelated(bvid: string): Promise<VideoItem[]> {
|
|
|
|
|
|
const res = await api.get('/x/web-interface/archive/related', { params: { bvid } });
|
|
|
|
|
|
const items: any[] = res.data.data ?? [];
|
|
|
|
|
|
return items as VideoItem[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 00:06:41 +08:00
|
|
|
|
export function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlResponse> {
|
2026-03-10 20:04:48 +08:00
|
|
|
|
const isAndroid = Platform.OS === 'android';
|
2026-03-19 16:38:18 +08:00
|
|
|
|
// 1488 = 16(DASH)|64(HDR)|128(4K)|256(杜比全景声)|1024(杜比视界)
|
|
|
|
|
|
const FNVAL_ANDROID = 16 | 64 | 128 | 256 | 1024;
|
2026-03-10 20:04:48 +08:00
|
|
|
|
const params = isAndroid
|
2026-03-19 16:38:18 +08:00
|
|
|
|
? { bvid, cid, qn, fnval: FNVAL_ANDROID, fourk: 1 }
|
2026-03-10 20:04:48 +08:00
|
|
|
|
: { bvid, cid, qn, fnval: 0, platform: 'html5', fourk: 1 };
|
2026-03-26 00:06:41 +08:00
|
|
|
|
return dedupe(dedupeKey('/x/player/playurl', params), async () => {
|
|
|
|
|
|
const res = await api.get('/x/player/playurl', { params });
|
|
|
|
|
|
return res.data.data as PlayUrlResponse;
|
|
|
|
|
|
});
|
2026-03-05 18:02:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 22:18:05 +08:00
|
|
|
|
export async function getPlayUrlForDownload(
|
|
|
|
|
|
bvid: string,
|
|
|
|
|
|
cid: number,
|
|
|
|
|
|
qn = 64,
|
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
|
const res = await api.get('/x/player/playurl', {
|
|
|
|
|
|
params: { bvid, cid, qn, fnval: 0, platform: 'html5' },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.data?.code !== 0) {
|
|
|
|
|
|
throw new Error(`API ${res.data?.code}: ${res.data?.message ?? '请求失败'}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
const durlItem = res.data?.data?.durl?.[0];
|
|
|
|
|
|
// 优先用主 URL,主 URL 失效时退到 backup_url
|
|
|
|
|
|
const url: string | undefined = durlItem?.url || (durlItem?.backup_url as string[] | undefined)?.[0];
|
|
|
|
|
|
if (!url) throw new Error('无法获取下载地址(durl 为空)');
|
|
|
|
|
|
return url;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 12:03:28 +08:00
|
|
|
|
export async function getUploaderStat(mid: number): Promise<{ follower: number; archiveCount: number }> {
|
|
|
|
|
|
const res = await api.get('/x/web-interface/card', { params: { mid } });
|
|
|
|
|
|
const data = res.data.data ?? {};
|
|
|
|
|
|
return {
|
|
|
|
|
|
follower: data.follower ?? 0,
|
|
|
|
|
|
archiveCount: data.archive_count ?? 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 00:06:41 +08:00
|
|
|
|
export async function getUploaderInfo(mid: number): Promise<{ name: string; face: string; sign: string; follower: number; archiveCount: number }> {
|
|
|
|
|
|
const res = await api.get('/x/web-interface/card', { params: { mid } });
|
|
|
|
|
|
const data = res.data.data ?? {};
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: data.card?.name ?? '',
|
|
|
|
|
|
face: data.card?.face ?? '',
|
|
|
|
|
|
sign: data.card?.sign ?? '',
|
|
|
|
|
|
follower: data.follower ?? 0,
|
|
|
|
|
|
archiveCount: data.archive_count ?? 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getUploaderVideos(mid: number, pn = 1, ps = 20): Promise<{ videos: VideoItem[]; total: number }> {
|
2026-03-26 00:26:10 +08:00
|
|
|
|
const { imgKey, subKey } = await getWbiKeys();
|
|
|
|
|
|
const signed = signWbi({ mid, pn, ps, order: 'pubdate', platform: 'web' }, imgKey, subKey);
|
|
|
|
|
|
const res = await api.get('/x/space/wbi/arc/search', { params: signed });
|
2026-03-26 00:06:41 +08:00
|
|
|
|
const vlist: any[] = res.data?.data?.list?.vlist ?? [];
|
|
|
|
|
|
const total: number = res.data?.data?.page?.count ?? 0;
|
|
|
|
|
|
const videos: VideoItem[] = vlist.map((v: any) => ({
|
|
|
|
|
|
bvid: v.bvid,
|
|
|
|
|
|
aid: v.aid ?? 0,
|
|
|
|
|
|
title: v.title,
|
|
|
|
|
|
pic: v.pic ? (v.pic.startsWith('//') ? `https:${v.pic}` : v.pic) : '',
|
|
|
|
|
|
owner: { mid, name: v.author ?? '', face: '' },
|
|
|
|
|
|
stat: {
|
|
|
|
|
|
view: v.play ?? 0,
|
|
|
|
|
|
danmaku: v.video_review ?? 0,
|
|
|
|
|
|
reply: v.comment ?? 0,
|
|
|
|
|
|
like: 0,
|
|
|
|
|
|
coin: 0,
|
|
|
|
|
|
favorite: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
duration: v.length ? parseDuration(v.length) : 0,
|
|
|
|
|
|
desc: v.description ?? '',
|
|
|
|
|
|
cid: v.cid ?? 0,
|
|
|
|
|
|
pages: [],
|
|
|
|
|
|
ugc_season: undefined,
|
|
|
|
|
|
}));
|
|
|
|
|
|
return { videos, total };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 19:04:18 +08:00
|
|
|
|
export async function getUserInfo(): Promise<{ face: string; uname: string; mid: number }> {
|
|
|
|
|
|
const res = await api.get('/x/web-interface/nav');
|
|
|
|
|
|
const { face, uname, mid } = res.data.data;
|
|
|
|
|
|
return { face: face ?? '', uname: uname ?? '', mid: mid ?? 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 14:24:32 +08:00
|
|
|
|
export async function getComments(
|
|
|
|
|
|
aid: number,
|
|
|
|
|
|
cursor = '',
|
|
|
|
|
|
sort = 2,
|
|
|
|
|
|
): Promise<{ replies: Comment[]; nextCursor: string; isEnd: boolean }> {
|
|
|
|
|
|
const mode = sort === 2 ? 3 : 2; // 3=hot, 2=time
|
|
|
|
|
|
const res = await api.get('/x/v2/reply/main', {
|
|
|
|
|
|
params: {
|
|
|
|
|
|
oid: aid,
|
|
|
|
|
|
type: 1,
|
|
|
|
|
|
mode,
|
|
|
|
|
|
plat: 1,
|
|
|
|
|
|
pagination_str: JSON.stringify({ offset: cursor }),
|
|
|
|
|
|
},
|
2026-03-05 18:02:54 +08:00
|
|
|
|
});
|
2026-03-16 14:24:32 +08:00
|
|
|
|
const data = res.data.data;
|
|
|
|
|
|
const replies = (data?.replies ?? []) as Comment[];
|
|
|
|
|
|
const nextCursor: string = data?.cursor?.next ?? '';
|
|
|
|
|
|
const isEnd: boolean = data?.cursor?.is_end ?? true;
|
|
|
|
|
|
return { replies, nextCursor, isEnd };
|
2026-03-05 18:02:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 20:04:48 +08:00
|
|
|
|
export async function getVideoShot(bvid: string, cid: number): Promise<VideoShotData | null> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await api.get('/x/player/videoshot', {
|
|
|
|
|
|
params: { bvid, cid, index: 1 },
|
|
|
|
|
|
});
|
|
|
|
|
|
return res.data.data as VideoShotData;
|
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 18:02:54 +08:00
|
|
|
|
export async function generateQRCode(): Promise<QRCodeInfo> {
|
2026-03-10 19:04:18 +08:00
|
|
|
|
const headers = isWeb
|
|
|
|
|
|
? {}
|
|
|
|
|
|
: { 'Referer': 'https://www.bilibili.com' };
|
|
|
|
|
|
const res = await axios.get(`${PASSPORT}/x/passport-login/web/qrcode/generate`, { headers });
|
2026-03-05 18:02:54 +08:00
|
|
|
|
return res.data.data as QRCodeInfo;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function pollQRCode(qrcode_key: string): Promise<{ code: number; cookie?: string }> {
|
2026-03-10 19:04:18 +08:00
|
|
|
|
const headers = isWeb
|
|
|
|
|
|
? {}
|
|
|
|
|
|
: { 'Referer': 'https://www.bilibili.com' };
|
2026-03-05 18:02:54 +08:00
|
|
|
|
const res = await axios.get(`${PASSPORT}/x/passport-login/web/qrcode/poll`, {
|
|
|
|
|
|
params: { qrcode_key },
|
2026-03-10 19:04:18 +08:00
|
|
|
|
headers,
|
2026-03-05 18:02:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
const { code } = res.data.data;
|
|
|
|
|
|
let cookie: string | undefined;
|
|
|
|
|
|
if (code === 0) {
|
2026-03-10 19:04:18 +08:00
|
|
|
|
if (isWeb) {
|
|
|
|
|
|
// Proxy relays SESSDATA via custom response header
|
|
|
|
|
|
cookie = res.headers['x-sessdata'] as string | undefined;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const setCookie = res.headers['set-cookie'];
|
2026-03-14 18:25:13 +08:00
|
|
|
|
const match = setCookie?.find((c: string) => c.includes('SESSDATA='));
|
2026-03-10 19:04:18 +08:00
|
|
|
|
if (match) {
|
2026-03-14 18:25:13 +08:00
|
|
|
|
const sessPart = match.split(';').find((p: string) => p.trim().startsWith('SESSDATA='));
|
|
|
|
|
|
if (sessPart) {
|
|
|
|
|
|
cookie = sessPart.trim().replace('SESSDATA=', '');
|
|
|
|
|
|
}
|
2026-03-10 19:04:18 +08:00
|
|
|
|
}
|
2026-03-05 18:02:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { code, cookie };
|
|
|
|
|
|
}
|
2026-03-10 22:46:29 +08:00
|
|
|
|
|
2026-03-11 20:53:18 +08:00
|
|
|
|
|
2026-03-13 21:52:09 +08:00
|
|
|
|
const LIVE_BASE = isWeb ? 'http://localhost:3001/bilibili-live' : 'https://api.live.bilibili.com';
|
|
|
|
|
|
|
|
|
|
|
|
export async function getLiveList(page = 1, parentAreaId = 0): Promise<LiveRoom[]> {
|
|
|
|
|
|
if (parentAreaId === 0) {
|
|
|
|
|
|
// 推荐:使用原有接口
|
|
|
|
|
|
const res = await api.get(`${LIVE_BASE}/xlive/web-interface/v1/webMain/getMoreRecList`, {
|
|
|
|
|
|
params: { platform: 'web', page, page_size: 20 },
|
|
|
|
|
|
});
|
|
|
|
|
|
const list: any[] = res.data.data?.recommend_room_list ?? [];
|
|
|
|
|
|
return list.map(item => ({
|
|
|
|
|
|
roomid: item.roomid,
|
|
|
|
|
|
uid: item.uid,
|
|
|
|
|
|
title: item.title,
|
|
|
|
|
|
uname: item.uname,
|
|
|
|
|
|
face: item.face,
|
|
|
|
|
|
cover: item.cover ?? item.user_cover ?? item.keyframe,
|
|
|
|
|
|
online: item.online,
|
|
|
|
|
|
area_name: item.area_v2_name ?? '',
|
|
|
|
|
|
parent_area_name: item.area_v2_parent_name ?? '',
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
// 分区筛选:使用 getRoomList 接口
|
|
|
|
|
|
const res = await api.get(`${LIVE_BASE}/room/v1/area/getRoomList`, {
|
|
|
|
|
|
params: {
|
|
|
|
|
|
parent_area_id: parentAreaId,
|
|
|
|
|
|
area_id: 0,
|
|
|
|
|
|
page,
|
|
|
|
|
|
page_size: 20,
|
|
|
|
|
|
sort_type: 'online',
|
|
|
|
|
|
platform: 'web',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const list: any[] = res.data.data ?? [];
|
|
|
|
|
|
return list.map(item => ({
|
|
|
|
|
|
roomid: item.roomid,
|
|
|
|
|
|
uid: item.uid,
|
|
|
|
|
|
title: item.title,
|
|
|
|
|
|
uname: item.uname,
|
|
|
|
|
|
face: item.face,
|
|
|
|
|
|
cover: item.cover ?? item.user_cover ?? item.keyframe,
|
|
|
|
|
|
online: item.online,
|
|
|
|
|
|
area_name: item.area_v2_name ?? item.areaName ?? '',
|
|
|
|
|
|
parent_area_name: item.area_v2_parent_name ?? item.parentAreaName ?? '',
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 16:20:59 +08:00
|
|
|
|
export async function getLiveRoomDetail(roomId: number): Promise<LiveRoomDetail> {
|
|
|
|
|
|
const res = await api.get(`${LIVE_BASE}/room/v1/Room/get_info`, {
|
|
|
|
|
|
params: { room_id: roomId },
|
|
|
|
|
|
});
|
|
|
|
|
|
return res.data.data as LiveRoomDetail;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getLiveAnchorInfo(roomId: number): Promise<LiveAnchorInfo> {
|
|
|
|
|
|
const res = await api.get(`${LIVE_BASE}/live_user/v1/UserInfo/get_anchor_in_room`, {
|
|
|
|
|
|
params: { roomid: roomId },
|
|
|
|
|
|
});
|
|
|
|
|
|
const info = res.data.data?.info ?? {};
|
|
|
|
|
|
return { uid: info.uid, uname: info.uname, face: info.face } as LiveAnchorInfo;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 21:13:26 +08:00
|
|
|
|
export async function getLiveStreamUrl(roomId: number, qn = 10000): Promise<LiveStreamInfo> {
|
2026-03-16 16:20:59 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await api.get(`${LIVE_BASE}/xlive/web-room/v2/index/getRoomPlayInfo`, {
|
2026-03-17 18:00:00 +08:00
|
|
|
|
params: { room_id: roomId, protocol: '0,1', format: '0,1,2', codec: '0', qn, platform: 'android' },
|
2026-03-16 16:20:59 +08:00
|
|
|
|
});
|
2026-03-16 21:13:26 +08:00
|
|
|
|
const playurl = res.data?.data?.playurl_info?.playurl;
|
|
|
|
|
|
const streams: any[] = playurl?.stream ?? [];
|
|
|
|
|
|
const gQnDesc: any[] = playurl?.g_qn_desc ?? [];
|
2026-03-25 12:03:28 +08:00
|
|
|
|
const qualities = gQnDesc
|
|
|
|
|
|
.map((q: any) => ({ qn: q.qn as number, desc: q.desc as string }))
|
|
|
|
|
|
.filter(q => q.qn <= 10000);
|
2026-03-16 21:13:26 +08:00
|
|
|
|
|
2026-03-16 16:20:59 +08:00
|
|
|
|
let hlsUrl = '';
|
|
|
|
|
|
let flvUrl = '';
|
2026-03-16 21:13:26 +08:00
|
|
|
|
let currentQn = 0;
|
2026-03-16 16:20:59 +08:00
|
|
|
|
|
|
|
|
|
|
const hlsStream = streams.find(s => s.protocol_name === 'http_hls');
|
|
|
|
|
|
if (hlsStream) {
|
|
|
|
|
|
const fmt = hlsStream.format?.find((f: any) => f.format_name === 'fmp4') ?? hlsStream.format?.[0];
|
|
|
|
|
|
const codec = fmt?.codec?.find((c: any) => c.codec_name === 'avc') ?? fmt?.codec?.[0];
|
|
|
|
|
|
const urlInfo = codec?.url_info?.[0];
|
|
|
|
|
|
if (urlInfo) {
|
2026-03-17 17:26:43 +08:00
|
|
|
|
hlsUrl = urlInfo.host + codec.base_url + (urlInfo.extra ?? '');
|
2026-03-16 21:13:26 +08:00
|
|
|
|
currentQn = codec.current_qn ?? 0;
|
2026-03-16 16:20:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const flvStream = streams.find(s => s.protocol_name === 'http_stream');
|
|
|
|
|
|
if (flvStream) {
|
|
|
|
|
|
const fmt = flvStream.format?.[0];
|
|
|
|
|
|
const codec = fmt?.codec?.find((c: any) => c.codec_name === 'avc') ?? fmt?.codec?.[0];
|
|
|
|
|
|
const urlInfo = codec?.url_info?.[0];
|
|
|
|
|
|
if (urlInfo) {
|
2026-03-17 17:26:43 +08:00
|
|
|
|
flvUrl = urlInfo.host + codec.base_url + (urlInfo.extra ?? '');
|
2026-03-16 16:20:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 21:13:26 +08:00
|
|
|
|
return { hlsUrl, flvUrl, qn: currentQn, qualities };
|
2026-03-16 16:20:59 +08:00
|
|
|
|
} catch {
|
2026-03-16 21:13:26 +08:00
|
|
|
|
return { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] };
|
2026-03-16 16:20:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 21:13:26 +08:00
|
|
|
|
function parseDuration(s: string): number {
|
|
|
|
|
|
const parts = s.split(':').map(Number);
|
|
|
|
|
|
return parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 00:06:41 +08:00
|
|
|
|
export async function searchVideos(keyword: string, page = 1, order = ''): Promise<VideoItem[]> {
|
2026-03-16 21:13:26 +08:00
|
|
|
|
const { imgKey, subKey } = await getWbiKeys();
|
2026-03-26 00:06:41 +08:00
|
|
|
|
const params: Record<string, any> = { keyword, search_type: 'video', page, page_size: 20 };
|
|
|
|
|
|
if (order) params.order = order;
|
2026-03-16 21:13:26 +08:00
|
|
|
|
const signed = signWbi(
|
2026-03-26 00:06:41 +08:00
|
|
|
|
params,
|
2026-03-16 21:13:26 +08:00
|
|
|
|
imgKey,
|
|
|
|
|
|
subKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
const res = await api.get('/x/web-interface/wbi/search/type', { params: signed });
|
|
|
|
|
|
const results: any[] = res.data.data?.result ?? [];
|
|
|
|
|
|
return results
|
|
|
|
|
|
.filter((item: any) => item.bvid && item.title)
|
|
|
|
|
|
.map((item: any) => ({
|
|
|
|
|
|
bvid: item.bvid,
|
|
|
|
|
|
aid: item.aid ?? 0,
|
|
|
|
|
|
title: item.title.replace(/<[^>]+>/g, ''),
|
|
|
|
|
|
pic: item.pic ? (item.pic.startsWith('//') ? `https:${item.pic}` : item.pic) : '',
|
|
|
|
|
|
owner: { mid: item.mid ?? 0, name: item.author ?? '', face: '' },
|
|
|
|
|
|
stat: {
|
|
|
|
|
|
view: item.play ?? 0,
|
|
|
|
|
|
danmaku: item.video_review ?? 0,
|
|
|
|
|
|
reply: item.review ?? 0,
|
|
|
|
|
|
like: 0,
|
|
|
|
|
|
coin: 0,
|
|
|
|
|
|
favorite: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
duration: item.duration ? parseDuration(item.duration) : 0,
|
|
|
|
|
|
desc: item.description ?? '',
|
|
|
|
|
|
cid: 0,
|
|
|
|
|
|
pages: [],
|
|
|
|
|
|
ugc_season: undefined,
|
|
|
|
|
|
} as VideoItem));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 19:52:26 +08:00
|
|
|
|
export async function getLiveDanmakuHistory(roomId: number): Promise<{
|
|
|
|
|
|
danmakus: DanmakuItem[];
|
|
|
|
|
|
adminMsgs: string[];
|
|
|
|
|
|
}> {
|
|
|
|
|
|
const res = await api.get(`${LIVE_BASE}/xlive/web-room/v1/dM/gethistory`, {
|
|
|
|
|
|
params: { roomid: roomId },
|
2026-03-17 15:14:23 +08:00
|
|
|
|
});
|
2026-03-17 19:52:26 +08:00
|
|
|
|
|
|
|
|
|
|
const room: any[] = res.data?.data?.room ?? [];
|
|
|
|
|
|
const admin: any[] = res.data?.data?.admin ?? [];
|
|
|
|
|
|
const adminMsgs = admin.map((a: any) => a.text ?? '').filter(Boolean);
|
|
|
|
|
|
const danmakus = room.map((m: any) => ({
|
|
|
|
|
|
time: 0,
|
|
|
|
|
|
mode: 1 as const,
|
|
|
|
|
|
fontSize: 25,
|
|
|
|
|
|
color: m.text_color ? parseInt(m.text_color.replace('#', ''), 16) : 0xffffff,
|
|
|
|
|
|
text: m.text ?? '',
|
|
|
|
|
|
uname: m.nickname ?? m.uname,
|
|
|
|
|
|
isAdmin: m.isadmin === 1,
|
|
|
|
|
|
guardLevel: m.guard_level ?? 0,
|
|
|
|
|
|
medalLevel: Array.isArray(m.medal) && m.medal.length > 0 ? m.medal[0] as number : undefined,
|
|
|
|
|
|
medalName: Array.isArray(m.medal) && m.medal.length > 1 ? m.medal[1] as string : undefined,
|
|
|
|
|
|
timeline: m.timeline as string | undefined,
|
|
|
|
|
|
}));
|
|
|
|
|
|
return { danmakus, adminMsgs };
|
2026-03-17 15:14:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 22:46:29 +08:00
|
|
|
|
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
|
|
|
|
|
try {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
if (isWeb) {
|
|
|
|
|
|
// web 走代理,代理已解压,直接拿文本
|
|
|
|
|
|
const res = await axios.get(`${COMMENT_BASE}/${cid}.xml`, {
|
|
|
|
|
|
headers: {},
|
|
|
|
|
|
responseType: 'text',
|
|
|
|
|
|
});
|
|
|
|
|
|
return parseDanmakuXml(res.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 21:21:01 +08:00
|
|
|
|
// Native:arraybuffer + 逐一尝试解压(服务器强制压缩,无法避免)
|
2026-03-11 10:23:47 +08:00
|
|
|
|
const res = await axios.get(`${COMMENT_BASE}/${cid}.xml`, {
|
2026-03-11 20:53:18 +08:00
|
|
|
|
headers: { Referer: 'https://www.bilibili.com', 'User-Agent': UA },
|
2026-03-12 21:21:01 +08:00
|
|
|
|
responseType: 'arraybuffer',
|
2026-03-10 22:46:29 +08:00
|
|
|
|
});
|
2026-03-11 20:53:18 +08:00
|
|
|
|
|
2026-03-17 15:14:23 +08:00
|
|
|
|
const bytes = new Uint8Array(res.data as ArrayBuffer);
|
2026-03-12 21:21:01 +08:00
|
|
|
|
let xmlText: string | undefined;
|
2026-03-11 20:53:18 +08:00
|
|
|
|
|
2026-03-12 21:21:01 +08:00
|
|
|
|
// 依次尝试:inflate (gzip/zlib) → inflateRaw (raw deflate)
|
|
|
|
|
|
for (const fn of [pako.inflate, pako.inflateRaw] as Array<(input: Uint8Array, opts: pako.InflateOptions) => string>) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
xmlText = fn(bytes, { to: 'string' });
|
|
|
|
|
|
if (xmlText.includes('<d ')) break;
|
|
|
|
|
|
xmlText = undefined;
|
|
|
|
|
|
} catch { /* 继续尝试下一种 */ }
|
|
|
|
|
|
}
|
2026-03-11 20:53:18 +08:00
|
|
|
|
|
2026-03-12 21:21:01 +08:00
|
|
|
|
if (!xmlText) {
|
|
|
|
|
|
// 最后尝试当作明文
|
2026-03-11 20:53:18 +08:00
|
|
|
|
xmlText = new TextDecoder('utf-8').decode(bytes);
|
|
|
|
|
|
}
|
2026-03-12 21:21:01 +08:00
|
|
|
|
|
2026-03-11 20:53:18 +08:00
|
|
|
|
return parseDanmakuXml(xmlText);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('getDanmaku failed:', e);
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
2026-03-10 22:46:29 +08:00
|
|
|
|
}
|
2026-03-19 20:46:16 +08:00
|
|
|
|
|
|
|
|
|
|
export async function getFollowedLiveRooms(): Promise<LiveRoom[]> {
|
|
|
|
|
|
const res = await api.get(`${LIVE_BASE}/xlive/web-ucenter/v1/xfetter/FeedList`, {
|
2026-03-26 00:39:19 +08:00
|
|
|
|
params: { page: 1, page_size: 30, platform: 'web' },
|
|
|
|
|
|
headers: { Referer: 'https://live.bilibili.com' },
|
2026-03-19 20:46:16 +08:00
|
|
|
|
});
|
2026-03-26 00:39:19 +08:00
|
|
|
|
if (res.data?.code !== 0) {
|
|
|
|
|
|
console.warn('getFollowedLiveRooms error:', res.data?.code, res.data?.message);
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
// B站不同版本接口返回字段可能为 list 或 rooms
|
|
|
|
|
|
const list: any[] = res.data?.data?.list ?? res.data?.data?.rooms ?? [];
|
2026-03-19 20:46:16 +08:00
|
|
|
|
return list.map((r: any) => ({
|
2026-03-19 21:53:08 +08:00
|
|
|
|
roomid: r.room_id ?? r.roomid,
|
2026-03-19 20:46:16 +08:00
|
|
|
|
uid: r.uid,
|
|
|
|
|
|
title: r.title,
|
|
|
|
|
|
uname: r.uname,
|
|
|
|
|
|
face: r.face,
|
|
|
|
|
|
cover: r.cover || r.keyframe || '',
|
|
|
|
|
|
online: r.online ?? 0,
|
|
|
|
|
|
area_name: r.area_v2_name ?? '',
|
|
|
|
|
|
parent_area_name: r.area_v2_parent_name ?? '',
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
2026-03-26 00:06:41 +08:00
|
|
|
|
|
|
|
|
|
|
export async function getSearchSuggest(term: string): Promise<SearchSuggestItem[]> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await api.get('/x/web-interface/search/suggest', {
|
|
|
|
|
|
params: { term, main_ver: 'v1', highlight: '' },
|
|
|
|
|
|
});
|
|
|
|
|
|
const tags: any[] = res.data?.result?.tag ?? [];
|
|
|
|
|
|
return tags.map((t: any) => ({ value: t.value ?? '', ref: t.ref ?? 0 }));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getHotSearch(): Promise<HotSearchItem[]> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await api.get('/x/web-interface/wbi/search/square', {
|
|
|
|
|
|
params: { limit: 10 },
|
|
|
|
|
|
});
|
|
|
|
|
|
const trending: any[] = res.data?.data?.trending?.list ?? [];
|
|
|
|
|
|
return trending.map((t: any) => ({
|
|
|
|
|
|
keyword: t.keyword ?? '',
|
|
|
|
|
|
show_name: t.show_name ?? t.keyword ?? '',
|
|
|
|
|
|
icon: t.icon,
|
|
|
|
|
|
}));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|