import axios from 'axios'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Platform } from 'react-native'; import pako from 'pako'; import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom, LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo, SearchSuggestItem, HotSearchItem } from './types'; import { signWbi } from '../utils/wbi'; import { parseDanmakuXml } from '../utils/danmaku'; import { getSecure, setSecure } from '../utils/secureStorage'; const isWeb = Platform.OS === 'web'; 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'; const BASE = isWeb ? 'http://localhost:3001/bilibili-api' : 'https://api.bilibili.com'; const PASSPORT = isWeb ? 'http://localhost:3001/bilibili-passport' : 'https://passport.bilibili.com'; const COMMENT_BASE = isWeb ? 'http://localhost:3001/bilibili-comment' : 'https://comment.bilibili.com'; 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 { let buvid3 = await AsyncStorage.getItem('buvid3'); if (!buvid3) { buvid3 = generateBuvid3(); await AsyncStorage.setItem('buvid3', buvid3); } return buvid3; } const api = axios.create({ baseURL: BASE, timeout: 10000, headers: isWeb ? { 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'zh-CN,zh;q=0.9', } : { 'User-Agent': UA, 'Referer': 'https://www.bilibili.com', 'Origin': 'https://www.bilibili.com', 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'zh-CN,zh;q=0.9', }, }); api.interceptors.request.use(async (config) => { const [sessdata, biliJct, buvid3] = await Promise.all([ getSecure('SESSDATA'), getSecure('bili_jct'), getBuvid3(), ]); if (isWeb) { // Browsers block Cookie/Referer/Origin headers; relay via custom headers to proxy if (buvid3) config.headers['X-Buvid3'] = buvid3; if (sessdata) config.headers['X-Sessdata'] = sessdata; if (biliJct) config.headers['X-Bili-Jct'] = biliJct; } else { const cookies: string[] = [`buvid3=${buvid3}`]; if (sessdata) cookies.push(`SESSDATA=${sessdata}`); if (biliJct) cookies.push(`bili_jct=${biliJct}`); config.headers['Cookie'] = cookies.join('; '); } return config; }); // 被动累积:任何响应 Set-Cookie 里出现 bili_jct 都顺手存下来。覆盖老用户没有 CSRF 的情况。 api.interceptors.response.use(async (response) => { if (isWeb) return response; const setCookie = response.headers?.['set-cookie']; if (Array.isArray(setCookie)) { for (const c of setCookie) { const part = c.split(';').find((p: string) => p.trim().startsWith('bili_jct=')); if (part) { const value = part.trim().replace('bili_jct=', ''); if (value) await setSecure('bili_jct', value); } } } return response; }); // ─── Request deduplication ────────────────────────────────────────────────── // Prevents identical concurrent requests (same URL + params) from hitting the network twice. const inflightRequests = new Map>(); function dedupeKey(url: string, params?: Record): 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(key: string, fn: () => Promise): Promise { const existing = inflightRequests.get(key); if (existing) return existing as Promise; const promise = fn().finally(() => { inflightRequests.delete(key); }); inflightRequests.set(key, promise); return promise; } /** * 指数退避重试包装。仅对网络层面错误(无 response、5xx、超时)重试; * 业务错误(如 401/403/-352)直接抛出,不重试。 */ async function withRetry( fn: () => Promise, retries = 2, backoff = [500, 1500], ): Promise { let lastErr: any; for (let attempt = 0; attempt <= retries; attempt++) { try { return await fn(); } catch (e: any) { lastErr = e; const status = e?.response?.status; const isNetwork = !e?.response || e?.code === 'ECONNABORTED'; const is5xx = typeof status === 'number' && status >= 500; if (!isNetwork && !is5xx) throw e; if (attempt === retries) throw e; await new Promise((r) => setTimeout(r, backoff[attempt] ?? backoff[backoff.length - 1])); } } throw lastErr; } // WBI key cache (rotates ~daily) let wbiKeys: { imgKey: string; subKey: string } | null = null; let wbiKeysTimestamp = 0; const WBI_KEYS_TTL = 12 * 60 * 60 * 1000; // 12 hours async function getWbiKeys(): Promise<{ imgKey: string; subKey: string }> { 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; } } export async function getRecommendFeed(freshIdx = 0): Promise { const { imgKey, subKey } = await getWbiKeys(); const signed = signWbi( { fresh_type: 3, fresh_idx: freshIdx, fresh_idx_1h: freshIdx, ps: 21, feed_version: 'V8' }, imgKey, subKey, ); const res = await api.get('/x/web-interface/wbi/index/top/feed/rcmd', { params: signed }); const items: any[] = res.data.data?.item ?? []; return items .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)); } export async function getPopularVideos(pn = 1): Promise { const res = await api.get('/x/web-interface/popular', { params: { pn, ps: 20 } }); return res.data.data.list as VideoItem[]; } export function getVideoDetail(bvid: string): Promise { return dedupe(dedupeKey('/x/web-interface/view', { bvid }), () => withRetry(async () => { const res = await api.get('/x/web-interface/view', { params: { bvid } }); return res.data.data as VideoItem; }), ); } export async function getVideoRelated(bvid: string): Promise { const res = await api.get('/x/web-interface/archive/related', { params: { bvid } }); const items: any[] = res.data.data ?? []; return items as VideoItem[]; } export function getPlayUrl(bvid: string, cid: number, qn = 64): Promise { const isAndroid = Platform.OS === 'android'; // 1488 = 16(DASH)|64(HDR)|128(4K)|256(杜比全景声)|1024(杜比视界) const FNVAL_ANDROID = 16 | 64 | 128 | 256 | 1024; const params = isAndroid ? { bvid, cid, qn, fnval: FNVAL_ANDROID, fourk: 1 } : { bvid, cid, qn, fnval: 0, platform: 'html5', fourk: 1 }; return dedupe(dedupeKey('/x/player/playurl', params), () => withRetry(async () => { const res = await api.get('/x/player/playurl', { params }); return res.data.data as PlayUrlResponse; }), ); } export async function getPlayUrlForDownload( bvid: string, cid: number, qn = 64, ): Promise { 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; } 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, }; } 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 }> { 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 }); 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 }; } 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 }; } 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 }), }, }); 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 }; } export async function getVideoShot(bvid: string, cid: number): Promise { try { const res = await api.get('/x/player/videoshot', { params: { bvid, cid, index: 1 }, }); return res.data.data as VideoShotData; } catch { return null; } } export async function generateQRCode(): Promise { const headers = isWeb ? {} : { 'Referer': 'https://www.bilibili.com' }; const res = await axios.get(`${PASSPORT}/x/passport-login/web/qrcode/generate`, { headers }); return res.data.data as QRCodeInfo; } export async function pollQRCode(qrcode_key: string): Promise<{ code: number; cookie?: string; csrf?: string }> { const headers = isWeb ? {} : { 'Referer': 'https://www.bilibili.com' }; const res = await axios.get(`${PASSPORT}/x/passport-login/web/qrcode/poll`, { params: { qrcode_key }, headers, }); const { code } = res.data.data; let cookie: string | undefined; let csrf: string | undefined; if (code === 0) { if (isWeb) { // Proxy relays SESSDATA via custom response header cookie = res.headers['x-sessdata'] as string | undefined; csrf = res.headers['x-bili-jct'] as string | undefined; } else { const setCookie = res.headers['set-cookie']; if (Array.isArray(setCookie)) { for (const c of setCookie) { const sessPart = c.split(';').find((p: string) => p.trim().startsWith('SESSDATA=')); if (sessPart) cookie = sessPart.trim().replace('SESSDATA=', ''); const jctPart = c.split(';').find((p: string) => p.trim().startsWith('bili_jct=')); if (jctPart) csrf = jctPart.trim().replace('bili_jct=', ''); } } } } return { code, cookie, csrf }; } // 查关注状态(attribute: 0=未关注, 2=关注, 6=互相关注) export async function getRelation(mid: number): Promise<{ following: boolean; mutual: boolean }> { try { const res = await api.get('/x/relation', { params: { fid: mid } }); const attribute = res.data?.data?.attribute ?? 0; return { following: attribute === 2 || attribute === 6, mutual: attribute === 6, }; } catch { return { following: false, mutual: false }; } } /** 修改与 UP 主的关系:act=1 关注,act=2 取消关注。失败抛错(消息文本来自 B 站 code/message) */ export async function modifyRelation(mid: number, act: 1 | 2): Promise { const biliJct = await getSecure('bili_jct'); if (!biliJct) throw new Error('NO_CSRF'); // application/x-www-form-urlencoded body const body = `fid=${encodeURIComponent(String(mid))}` + `&act=${act}` + `&re_src=11` + `&csrf=${encodeURIComponent(biliJct)}`; const res = await api.post('/x/relation/modify', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); const code = res.data?.code; if (code !== 0) { throw new Error(res.data?.message || `code=${code}`); } } const LIVE_BASE = isWeb ? 'http://localhost:3001/bilibili-live' : 'https://api.live.bilibili.com'; export async function getLiveList(page = 1, parentAreaId = 0): Promise { 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 ?? '', })); } export async function getLiveRoomDetail(roomId: number): Promise { 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 { 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; } export async function getLiveStreamUrl(roomId: number, qn = 10000): Promise { try { const res = await api.get(`${LIVE_BASE}/xlive/web-room/v2/index/getRoomPlayInfo`, { params: { room_id: roomId, protocol: '0,1', format: '0,1,2', codec: '0', qn, platform: 'android' }, }); const playurl = res.data?.data?.playurl_info?.playurl; const streams: any[] = playurl?.stream ?? []; const gQnDesc: any[] = playurl?.g_qn_desc ?? []; const qualities = gQnDesc .map((q: any) => ({ qn: q.qn as number, desc: q.desc as string })) .filter(q => q.qn <= 10000); let hlsUrl = ''; let flvUrl = ''; let currentQn = 0; 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) { hlsUrl = urlInfo.host + codec.base_url + (urlInfo.extra ?? ''); currentQn = codec.current_qn ?? 0; } } 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) { flvUrl = urlInfo.host + codec.base_url + (urlInfo.extra ?? ''); } } return { hlsUrl, flvUrl, qn: currentQn, qualities }; } catch { return { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] }; } } 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]; } export async function searchVideos(keyword: string, page = 1, order = ''): Promise { const { imgKey, subKey } = await getWbiKeys(); const params: Record = { keyword, search_type: 'video', page, page_size: 20 }; if (order) params.order = order; const signed = signWbi( params, 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)); } export async function getLiveDanmakuHistory(roomId: number): Promise<{ danmakus: DanmakuItem[]; adminMsgs: string[]; }> { // gethistory 默认返回 ~10 条;尝试带 count 参数拉更多,B 站不支持时会自动忽略 const res = await api.get(`${LIVE_BASE}/xlive/web-room/v1/dM/gethistory`, { params: { roomid: roomId, room_id: roomId, count: 30 }, }); 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 }; } export async function getDanmaku(cid: number): Promise { return withRetry(async () => { if (isWeb) { // web 走代理,代理已解压,直接拿文本 const res = await axios.get(`${COMMENT_BASE}/${cid}.xml`, { headers: {}, responseType: 'text', }); return parseDanmakuXml(res.data); } // Native:arraybuffer + 逐一尝试解压(服务器强制压缩,无法避免) const res = await axios.get(`${COMMENT_BASE}/${cid}.xml`, { headers: { Referer: 'https://www.bilibili.com', 'User-Agent': UA }, responseType: 'arraybuffer', }); const bytes = new Uint8Array(res.data as ArrayBuffer); let xmlText: string | undefined; // 依次尝试: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(' { console.warn('getDanmaku failed:', e); return [] as DanmakuItem[]; }); } export async function getFollowedLiveRooms(): Promise { const res = await api.get(`${LIVE_BASE}/xlive/web-ucenter/v1/xfetter/FeedList`, { params: { page: 1, page_size: 10, platform: 'web' }, }); 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 ?? []; return list.map((r: any) => ({ roomid: r.room_id ?? r.roomid, 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 ?? '', })); } export async function getSearchSuggest(term: string): Promise { 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 { 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 []; } }