Skip to content

Commit ec54606

Browse files
committed
feat(hupu): game result
1 parent 36dfd46 commit ec54606

File tree

2 files changed

+225
-18
lines changed

2 files changed

+225
-18
lines changed

lib/routes/hupu/index.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import timezone from '@/utils/timezone';
55

66
import type { HomePostItem, HupuApiResponse, NewsDataItem } from './types';
77
import { isHomePostItem } from './types';
8-
import { getEntryDetails } from './utils';
8+
import { extractNextData, getEntryDetails } from './utils';
99

1010
const categories = {
1111
nba: {
@@ -67,21 +67,7 @@ export const route: Route = {
6767
url: currentUrl,
6868
});
6969

70-
const scriptMatch = response.data.match(/<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/);
71-
if (!scriptMatch || !scriptMatch[1]) {
72-
throw new Error(`Failed to find __NEXT_DATA__ script tag in page: ${currentUrl}`);
73-
}
74-
75-
const fullJsonString = scriptMatch[1];
76-
let fullData;
77-
78-
try {
79-
fullData = JSON.parse(fullJsonString);
80-
} catch (error) {
81-
throw new Error(`Failed to parse full JSON data: ${error instanceof Error ? error.message : String(error)}`);
82-
}
83-
84-
const data: HupuApiResponse = fullData;
70+
const data = extractNextData<HupuApiResponse>(response.data, currentUrl);
8571
const { pageProps } = data.props;
8672

8773
const dataKey = categories[category].data;

lib/routes/hupu/utils.ts

Lines changed: 223 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,205 @@ import got from '@/utils/got';
66
import { parseDate, parseRelativeDate } from '@/utils/parse-date';
77
import timezone from '@/utils/timezone';
88

9+
export function extractNextData<T = unknown>(html: string, url?: string): T {
10+
const scriptMatch = html.match(/<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/);
11+
if (!scriptMatch || !scriptMatch[1]) {
12+
throw new Error(`Failed to find __NEXT_DATA__ script tag in page${url ? `: ${url}` : ''}`);
13+
}
14+
15+
try {
16+
return JSON.parse(scriptMatch[1]) as T;
17+
} catch (error) {
18+
throw new Error(`Failed to parse __NEXT_DATA__ JSON: ${error instanceof Error ? error.message : String(error)}`);
19+
}
20+
}
21+
22+
interface ThreadNextData {
23+
props: {
24+
pageProps: {
25+
threadData: {
26+
data: {
27+
moduleConfigList: {
28+
content: {
29+
moduleContent: {
30+
content: string;
31+
};
32+
};
33+
};
34+
};
35+
};
36+
};
37+
};
38+
}
39+
40+
interface MatchContent {
41+
team?: string;
42+
url?: string;
43+
matchId?: string;
44+
type?: string;
45+
}
46+
47+
function hexToRgb(hex: string): string {
48+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
49+
if (result) {
50+
const r = Number.parseInt(result[1], 16);
51+
const g = Number.parseInt(result[2], 16);
52+
const b = Number.parseInt(result[3], 16);
53+
return `rgb(${r}, ${g}, ${b})`;
54+
}
55+
return hex;
56+
}
57+
58+
interface PlayerStats {
59+
playerId: string;
60+
playerName: string;
61+
alias: string;
62+
photo: string;
63+
mins: number;
64+
pts: number;
65+
reb: number;
66+
asts: number;
67+
stl: number;
68+
blk: number;
69+
nfg: string;
70+
fgp: string;
71+
threePoints: string;
72+
tpp: string;
73+
ft: string;
74+
ftp: string;
75+
to: number;
76+
oreb: number;
77+
dreb: number;
78+
blkr: number;
79+
pf: number;
80+
foulr: number;
81+
plusMinus: string;
82+
}
83+
84+
interface TeamPlayerStats {
85+
teamName: string;
86+
teamColor: string;
87+
start: PlayerStats[];
88+
reserve: PlayerStats[];
89+
dnpPlayerList: string[];
90+
}
91+
92+
interface MatchStats {
93+
firstTeam: {
94+
teamName: string;
95+
totalScore: number;
96+
section: number[];
97+
};
98+
secondTeam: {
99+
teamName: string;
100+
totalScore: number;
101+
section: number[];
102+
};
103+
}
104+
105+
interface TeamStats {
106+
leftTeam: {
107+
teamName: string;
108+
teamColor: string;
109+
pts: number;
110+
reb: number;
111+
asts: number;
112+
stl: number;
113+
blk: number;
114+
};
115+
rightTeam: {
116+
teamName: string;
117+
teamColor: string;
118+
pts: number;
119+
reb: number;
120+
asts: number;
121+
stl: number;
122+
blk: number;
123+
};
124+
}
125+
126+
interface GameStatusResult {
127+
playerStats: {
128+
first: TeamPlayerStats;
129+
second: TeamPlayerStats;
130+
};
131+
matchStats: MatchStats;
132+
teamStats: TeamStats;
133+
playerVertical: string[][];
134+
teamVertical: string[][];
135+
}
136+
137+
function generatePlayerRow(player: PlayerStats, isStart: boolean): string {
138+
return `<tr><td><div class="body-cell cell${isStart ? ' start' : ''}"><span style="padding-left: 15px">${player.alias}</span></div></td></tr>`;
139+
}
140+
141+
function generatePlayerDataRow(player: PlayerStats): string {
142+
const plusMinus = player.plusMinus.startsWith('-') ? player.plusMinus : `+${player.plusMinus}`;
143+
return `<tr><td><div class="body-cell cell">${player.mins}</div></td><td><div class="body-cell cell">${player.pts}</div></td><td><div class="body-cell cell">${player.reb}</div></td><td><div class="body-cell cell">${player.asts}</div></td><td><div class="body-cell cell">${player.stl}</div></td><td><div class="body-cell cell">${player.blk}</div></td><td><div class="body-cell cell">${player.nfg}</div></td><td><div class="body-cell cell">${player.fgp}</div></td><td><div class="body-cell cell">${player.threePoints}</div></td><td><div class="body-cell cell">${player.tpp}</div></td><td><div class="body-cell cell">${player.ft}</div></td><td><div class="body-cell cell">${player.ftp}</div></td><td><div class="body-cell cell">${player.to}</div></td><td><div class="body-cell cell">${player.oreb}</div></td><td><div class="body-cell cell">${player.dreb}</div></td><td><div class="body-cell cell">${player.blkr}</div></td><td><div class="body-cell cell">${player.pf}</div></td><td><div class="body-cell cell">${player.foulr}</div></td><td><div class="body-cell cell">${plusMinus}</div></td></tr>`;
144+
}
145+
146+
function generateTeamPlayerTable(team: TeamPlayerStats): string {
147+
const allPlayers = [...team.start, ...team.reserve];
148+
const playerNameRows = allPlayers.map((player, index) => generatePlayerRow(player, index < team.start.length)).join('');
149+
const playerDataRows = allPlayers.map((player) => generatePlayerDataRow(player)).join('');
150+
const teamColor = hexToRgb(team.teamColor);
151+
152+
const headerRow = `<tr><td><div class="body-cell cell">时间</div></td><td><div class="body-cell cell">得分</div></td><td><div class="body-cell cell">篮板</div></td><td><div class="body-cell cell">助攻</div></td><td><div class="body-cell cell">抢断</div></td><td><div class="body-cell cell">盖帽</div></td><td><div class="body-cell cell">投篮</div></td><td><div class="body-cell cell">投篮%</div></td><td><div class="body-cell cell">三分</div></td><td><div class="body-cell cell">三分%</div></td><td><div class="body-cell cell">罚球</div></td><td><div class="body-cell cell">罚球%</div></td><td><div class="body-cell cell">失误</div></td><td><div class="body-cell cell">前板</div></td><td><div class="body-cell cell">后板</div></td><td><div class="body-cell cell">被盖</div></td><td><div class="body-cell cell">犯规</div></td><td><div class="body-cell cell">被犯</div></td><td><div class="body-cell cell">+/-</div></td></tr>`;
153+
154+
return `<div class="match-player-data"><div class="table-wrap-views"><div class="table-views table-views-body"><div class="table-body-left"><table cellspacing="0" cellpadding="0"><tbody><tr><td><div class="body-cell cell team" style="border-color: ${teamColor}"><span style="padding-left: 15px">${team.teamName}</span></div></td></tr>${playerNameRows}</tbody></table></div><div class="table-body-right"><table cellspacing="0" cellpadding="0"><tbody>${headerRow}${playerDataRows}</tbody></table></div></div></div></div>${team.dnpPlayerList.length > 0 ? `<div class="not-play mlr-15"><span>未出场队员:</span>${team.dnpPlayerList.join('、')}</div>` : ''}`;
155+
}
156+
157+
function generateTeamCompareItem(leftValue: number, rightValue: number, label: string, leftColor: string): string {
158+
const total = leftValue + rightValue;
159+
const leftPercent = total > 0 ? (leftValue / total) * 100 : 50;
160+
const rightPercent = total > 0 ? (rightValue / total) * 100 : 50;
161+
162+
return `<div class="team-compare"><div class="team-item-data"><span class="left">${leftValue}</span><span class="center">${label}</span><span class="right">${rightValue}</span></div><div class="compare-item"><div class="item left"><span class="" style="width: ${leftPercent.toFixed(4)}%; background-color: ${leftColor}"></span></div><div class="item right"><span class="gray" style="width: ${rightPercent.toFixed(4)}%"></span></div></div></div>`;
163+
}
164+
165+
export function generateGameStatusHtml(result: GameStatusResult): string {
166+
const { playerStats, matchStats, teamStats } = result;
167+
168+
// Player data section
169+
const playerDataHtml = `<div class="post-player"><div class="post-title mlr-15">球员数据<span class="tips">可横滑查看更多数据</span></div>${generateTeamPlayerTable(playerStats.first)}${generateTeamPlayerTable(playerStats.second)}</div>`;
170+
171+
// Team score table
172+
const sectionHeaders = matchStats.firstTeam.section.map((_, i) => `<td><div class="body-cell cell">${i + 1}</div></td>`).join('');
173+
const firstTeamSections = matchStats.firstTeam.section.map((s) => `<td><div class="body-cell cell">${s}</div></td>`).join('');
174+
const secondTeamSections = matchStats.secondTeam.section.map((s) => `<td><div class="body-cell cell">${s}</div></td>`).join('');
175+
176+
const teamScoreTable = `<div class="match-team-data"><div class="table-wrap-views team"><div class="table-views table-views-body"><div class="table-body-left"><table cellspacing="0" cellpadding="0"><tbody><tr><td><div class="body-cell cell"><span style="padding-left: 15px">球队</span></div></td></tr><tr><td><div class="body-cell cell"><span style="padding-left: 15px">${matchStats.firstTeam.teamName}</span></div></td></tr><tr><td><div class="body-cell cell"><span style="padding-left: 15px">${matchStats.secondTeam.teamName}</span></div></td></tr></tbody></table></div><div class="table-body-right"><table cellspacing="0" cellpadding="0"><tbody><tr><td><div class="body-cell cell">总分</div></td>${sectionHeaders}</tr><tr><td><div class="body-cell cell">${matchStats.firstTeam.totalScore}</div></td>${firstTeamSections}</tr><tr><td><div class="body-cell cell">${matchStats.secondTeam.totalScore}</div></td>${secondTeamSections}</tr></tbody></table></div></div></div>`;
177+
178+
// Team comparison
179+
const leftTeam = teamStats.leftTeam;
180+
const rightTeam = teamStats.rightTeam;
181+
const leftColor = hexToRgb(leftTeam.teamColor);
182+
const rightColor = hexToRgb(rightTeam.teamColor);
183+
184+
const teamCompareHtml = `<div class="team-name"><span style="background-color: ${leftColor}">${leftTeam.teamName}</span><span style="background-color: ${rightColor}">${rightTeam.teamName}</span></div><div class="match-team-compare">${generateTeamCompareItem(leftTeam.pts, rightTeam.pts, '得分', leftColor)}${generateTeamCompareItem(leftTeam.reb, rightTeam.reb, '篮板', leftColor)}${generateTeamCompareItem(leftTeam.asts, rightTeam.asts, '助攻', leftColor)}${generateTeamCompareItem(leftTeam.stl, rightTeam.stl, '抢断', leftColor)}${generateTeamCompareItem(leftTeam.blk, rightTeam.blk, '封盖', leftColor)}</div>`;
185+
186+
// Team data section
187+
const teamDataHtml = `<div class="post-team"><div class="post-title mlr-15">球队数据<span class="tips">可横滑查看更多数据</span></div>${teamScoreTable}${teamCompareHtml}</div><a href="https://mobile.hupu.com" rel="nofollow"><div class="see-more">进入直播间查看更多数据、视频</div></a>`;
188+
189+
return `<div class="match-post">${playerDataHtml}${teamDataHtml}</div>`;
190+
}
191+
192+
export async function getGameStatus(matchID: string): Promise<string | null> {
193+
const res = await got({
194+
method: 'get',
195+
url: 'https://games.mobileapi.hupu.com/1/7.4.4/basketballapi/matchCompletedAutoPostContent',
196+
searchParams: {
197+
matchId: matchID,
198+
},
199+
});
200+
const data = res.data;
201+
if (data?.result && data.result.playerStats && data.result.matchStats && data.result.teamStats) {
202+
const html = generateGameStatusHtml(data.result as GameStatusResult);
203+
return html;
204+
}
205+
return null;
206+
}
207+
9208
export function getEntryDetails(item: DataItem): Promise<DataItem> {
10209
if (!item.link) {
11210
return Promise.resolve(item);
@@ -17,7 +216,21 @@ export function getEntryDetails(item: DataItem): Promise<DataItem> {
17216
url: item.link,
18217
});
19218

20-
const content = load(detailResponse.data);
219+
const html = detailResponse.data;
220+
const content = load(html);
221+
222+
// Extract matchId from __NEXT_DATA__
223+
let matchId: string | null = null;
224+
try {
225+
const nextData = extractNextData<ThreadNextData>(html, item.link);
226+
const contentStr = nextData.props?.pageProps?.threadData?.data?.moduleConfigList?.content?.moduleContent?.content;
227+
if (contentStr) {
228+
const matchContent: MatchContent = JSON.parse(contentStr);
229+
matchId = matchContent.matchId ?? null;
230+
}
231+
} catch {
232+
// ignore
233+
}
21234

22235
const author = content('.bbs-user-info-name, .bbs-user-wrapper-content-name-span').text();
23236
const pubDateString = content('.second-line-user-info span:not([class])').text();
@@ -69,7 +282,15 @@ export function getEntryDetails(item: DataItem): Promise<DataItem> {
69282
}
70283
}
71284

72-
const description = descriptionParts.length > 0 ? descriptionParts.join('') : undefined;
285+
let description = descriptionParts.length > 0 ? descriptionParts.join('') : undefined;
286+
287+
// 如果有 matchId,获取比赛数据
288+
if (matchId) {
289+
const gameStatusHtml = await getGameStatus(matchId);
290+
if (gameStatusHtml) {
291+
description = (description ?? '') + gameStatusHtml;
292+
}
293+
}
73294

74295
return {
75296
...item,

0 commit comments

Comments
 (0)