diff --git a/components/VideoCard.tsx b/components/VideoCard.tsx index c58592d..bf506e7 100644 --- a/components/VideoCard.tsx +++ b/components/VideoCard.tsx @@ -29,9 +29,9 @@ export function VideoCard({ item, onPress }: Props) { {item.title} - {formatCount(item.stat.view)} + {formatCount(item.stat?.view ?? 0)} - {item.owner.name} + {item.owner?.name ?? ''} ); diff --git a/hooks/useVideoList.ts b/hooks/useVideoList.ts index 8adc6b8..8eb3bd2 100644 --- a/hooks/useVideoList.ts +++ b/hooks/useVideoList.ts @@ -1,32 +1,31 @@ import { useState, useCallback } from 'react'; -import { getPopularVideos } from '../services/bilibili'; +import { getRecommendFeed } from '../services/bilibili'; import type { VideoItem } from '../services/types'; export function useVideoList() { const [videos, setVideos] = useState([]); - const [page, setPage] = useState(1); + const [freshIdx, setFreshIdx] = useState(0); const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const load = useCallback(async (reset = false) => { if (loading) return; - const nextPage = reset ? 1 : page; + const idx = reset ? 0 : freshIdx; setLoading(true); try { - const data = await getPopularVideos(nextPage); + const data = await getRecommendFeed(idx); setVideos(prev => reset ? data : [...prev, ...data]); - setPage(nextPage + 1); + setFreshIdx(idx + 1); } catch (e) { console.error('Failed to load videos', e); } finally { setLoading(false); setRefreshing(false); } - }, [loading, page]); + }, [loading, freshIdx]); const refresh = useCallback(() => { setRefreshing(true); - setPage(1); load(true); }, [load]); diff --git a/services/bilibili.ts b/services/bilibili.ts index 28d3aaf..4095188 100644 --- a/services/bilibili.ts +++ b/services/bilibili.ts @@ -2,6 +2,7 @@ import axios from 'axios'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Platform } from 'react-native'; import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo } from './types'; +import { signWbi } from '../utils/wbi'; const isWeb = Platform.OS === 'web'; const BASE = isWeb ? 'http://localhost:3001/bilibili-api' : 'https://api.bilibili.com'; @@ -54,6 +55,35 @@ api.interceptors.request.use(async (config) => { return config; }); +// WBI key cache (rotates ~daily, reuse within process lifetime) +let wbiKeys: { imgKey: string; subKey: string } | null = null; + +async function getWbiKeys(): Promise<{ imgKey: string; subKey: string }> { + if (wbiKeys) return wbiKeys; + const res = await api.get('/x/web-interface/nav'); + const { img_url, sub_url } = res.data.data.wbi_img; + const extract = (url: string) => url.split('/').pop()!.replace(/\.\w+$/, ''); + wbiKeys = { imgKey: extract(img_url), subKey: extract(sub_url) }; + return wbiKeys; +} + +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: 12, 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.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[]; diff --git a/services/types.ts b/services/types.ts index 28a3146..a702f83 100644 --- a/services/types.ts +++ b/services/types.ts @@ -8,17 +8,17 @@ export interface VideoItem { name: string; face: string; }; - stat: { + stat?: { view: number; danmaku: number; reply: number; like: number; coin: number; favorite: number; - }; + } | null; duration: number; desc: string; - cid: number; + cid?: number; pages?: Array<{ cid: number; part: string }>; } diff --git a/utils/wbi.ts b/utils/wbi.ts new file mode 100644 index 0000000..42f07e5 --- /dev/null +++ b/utils/wbi.ts @@ -0,0 +1,79 @@ +// WBI signature algorithm for Bilibili API +// https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/sign/wbi.md + +const MIXIN_KEY_ENC_TAB = [ + 46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35, + 27,43,5,49,33,9,42,19,29,28,14,39,12,38,41,13, + 37,48,7,16,24,55,40,61,26,17,0,1,60,51,30,4, + 22,25,54,21,56,59,6,63,57,62,11,36,20,34,44,52, +]; + +function getMixinKey(imgKey: string, subKey: string): string { + const raw = imgKey + subKey; + return MIXIN_KEY_ENC_TAB.slice(0, 32).map(i => raw[i]).join(''); +} + +// Minimal MD5 implementation (no dependencies) +function md5(input: string): string { + function safeAdd(x: number, y: number) { const lsw=(x&0xffff)+(y&0xffff); const msw=(x>>16)+(y>>16)+(lsw>>16); return (msw<<16)|(lsw&0xffff); } + function bitRotateLeft(num: number, cnt: number) { return (num<>>(32-cnt)); } + function md5cmn(q:number,a:number,b:number,x:number,s:number,t:number){return safeAdd(bitRotateLeft(safeAdd(safeAdd(a,q),safeAdd(x,t)),s),b);} + function md5ff(a:number,b:number,c:number,d:number,x:number,s:number,t:number){return md5cmn((b&c)|(~b&d),a,b,x,s,t);} + function md5gg(a:number,b:number,c:number,d:number,x:number,s:number,t:number){return md5cmn((b&d)|(c&~d),a,b,x,s,t);} + function md5hh(a:number,b:number,c:number,d:number,x:number,s:number,t:number){return md5cmn(b^c^d,a,b,x,s,t);} + function md5ii(a:number,b:number,c:number,d:number,x:number,s:number,t:number){return md5cmn(c^(b|~d),a,b,x,s,t);} + + function md5blks(s: string): number[] { + const nblk = ((s.length+8)>>6)+1; + const blks = new Array(nblk*16).fill(0); + for(let i=0;i>2]|=s.charCodeAt(i)<<((i%4)*8); + blks[s.length>>2]|=0x80<<((s.length%4)*8); + blks[nblk*16-2]=s.length*8; + return blks; + } + + const x = md5blks(input); + let a=1732584193,b=-271733879,c=-1732584194,d=271733878; + for(let i=0;i>8)&0xff).toString(16)).slice(-2)+('0'+((n>>16)&0xff).toString(16)).slice(-2)+('0'+((n>>24)&0xff).toString(16)).slice(-2); } + return hex(a)+hex(b)+hex(c)+hex(d); +} + +export function signWbi( + params: Record, + imgKey: string, + subKey: string, +): Record { + const mixinKey = getMixinKey(imgKey, subKey); + const wts = Math.floor(Date.now() / 1000); + const all: Record = { ...params, wts }; + const query = Object.keys(all) + .sort() + .map(k => { + const v = String(all[k]).replace(/[!'()*]/g, ''); + return `${k}=${v}`; + }) + .join('&'); + const w_rid = md5(query + mixinKey); + return { ...all, w_rid }; +}