RSSHub/lib/routes/hupu/utils.ts

309 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { load } from 'cheerio';
import type { DataItem } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate, parseRelativeDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
export function extractNextData<T = unknown>(html: string, url?: string): T {
const scriptMatch = html.match(/<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/);
if (!scriptMatch || !scriptMatch[1]) {
throw new Error(`Failed to find __NEXT_DATA__ script tag in page${url ? `: ${url}` : ''}`);
}
try {
return JSON.parse(scriptMatch[1]) as T;
} catch (error) {
throw new Error(`Failed to parse __NEXT_DATA__ JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
}
}
interface ThreadNextData {
props: {
pageProps: {
threadData: {
data: {
moduleConfigList: {
content: {
moduleContent: {
content: string;
};
};
};
};
};
};
};
}
interface MatchContent {
team?: string;
url?: string;
matchId?: string;
type?: string;
}
function hexToRgb(hex: string): string {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
const r = Number.parseInt(result[1], 16);
const g = Number.parseInt(result[2], 16);
const b = Number.parseInt(result[3], 16);
return `rgb(${r}, ${g}, ${b})`;
}
return hex;
}
interface PlayerStats {
playerId: string;
playerName: string;
alias: string;
photo: string;
mins: number;
pts: number;
reb: number;
asts: number;
stl: number;
blk: number;
nfg: string;
fgp: string;
threePoints: string;
tpp: string;
ft: string;
ftp: string;
to: number;
oreb: number;
dreb: number;
blkr: number;
pf: number;
foulr: number;
plusMinus: string;
}
interface TeamPlayerStats {
teamName: string;
teamColor: string;
start: PlayerStats[];
reserve: PlayerStats[];
dnpPlayerList: string[];
}
interface MatchStats {
firstTeam: {
teamName: string;
totalScore: number;
section: number[];
};
secondTeam: {
teamName: string;
totalScore: number;
section: number[];
};
}
interface TeamStats {
leftTeam: {
teamName: string;
teamColor: string;
pts: number;
reb: number;
asts: number;
stl: number;
blk: number;
};
rightTeam: {
teamName: string;
teamColor: string;
pts: number;
reb: number;
asts: number;
stl: number;
blk: number;
};
}
interface GameStatusResult {
playerStats: {
first: TeamPlayerStats;
second: TeamPlayerStats;
};
matchStats: MatchStats;
teamStats: TeamStats;
playerVertical: string[][];
teamVertical: string[][];
}
function generatePlayerRow(player: PlayerStats, isStart: boolean): string {
return `<tr><td><div class="body-cell cell${isStart ? ' start' : ''}"><span style="padding-left: 15px">${player.alias}</span></div></td></tr>`;
}
function generatePlayerDataRow(player: PlayerStats): string {
const plusMinus = player.plusMinus.startsWith('-') ? player.plusMinus : `+${player.plusMinus}`;
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>`;
}
function generateTeamPlayerTable(team: TeamPlayerStats): string {
const allPlayers = [...team.start, ...team.reserve];
const playerNameRows = allPlayers.map((player, index) => generatePlayerRow(player, index < team.start.length)).join('');
const playerDataRows = allPlayers.map((player) => generatePlayerDataRow(player)).join('');
const teamColor = hexToRgb(team.teamColor);
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>';
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>` : ''}`;
}
function generateTeamCompareItem(leftValue: number, rightValue: number, label: string, leftColor: string): string {
const total = leftValue + rightValue;
const leftPercent = total > 0 ? (leftValue / total) * 100 : 50;
const rightPercent = total > 0 ? (rightValue / total) * 100 : 50;
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>`;
}
export function generateGameStatusHtml(result: GameStatusResult): string {
const { playerStats, matchStats, teamStats } = result;
// Player data section
const playerDataHtml = `<div class="post-player"><div class="post-title mlr-15">球员数据<span class="tips">可横滑查看更多数据</span></div>${generateTeamPlayerTable(playerStats.first)}${generateTeamPlayerTable(playerStats.second)}</div>`;
// Team score table
const sectionHeaders = matchStats.firstTeam.section.map((_, i) => `<td><div class="body-cell cell">${i + 1}</div></td>`).join('');
const firstTeamSections = matchStats.firstTeam.section.map((s) => `<td><div class="body-cell cell">${s}</div></td>`).join('');
const secondTeamSections = matchStats.secondTeam.section.map((s) => `<td><div class="body-cell cell">${s}</div></td>`).join('');
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>`;
// Team comparison
const leftTeam = teamStats.leftTeam;
const rightTeam = teamStats.rightTeam;
const leftColor = hexToRgb(leftTeam.teamColor);
const rightColor = hexToRgb(rightTeam.teamColor);
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>`;
// Team data section
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>`;
return `<div class="match-post">${playerDataHtml}${teamDataHtml}</div>`;
}
export async function getGameStatus(matchID: string): Promise<string | null> {
const res = await got({
method: 'get',
url: 'https://games.mobileapi.hupu.com/1/7.4.4/basketballapi/matchCompletedAutoPostContent',
searchParams: {
matchId: matchID,
},
});
const data = res.data;
if (data?.result && data.result.playerStats && data.result.matchStats && data.result.teamStats) {
const html = generateGameStatusHtml(data.result as GameStatusResult);
return html;
}
return null;
}
export function getEntryDetails(item: DataItem): Promise<DataItem> {
if (!item.link) {
return Promise.resolve(item);
}
return cache.tryGet(item.link!, async () => {
try {
const detailResponse = await got({
method: 'get',
url: item.link,
});
const html = detailResponse.data;
const content = load(html);
// Extract matchId from __NEXT_DATA__
let matchId: string | null = null;
try {
const nextData = extractNextData<ThreadNextData>(html, item.link);
const contentStr = nextData.props?.pageProps?.threadData?.data?.moduleConfigList?.content?.moduleContent?.content;
if (contentStr) {
const matchContent: MatchContent = JSON.parse(contentStr);
matchId = matchContent.matchId ?? null;
}
} catch {
// ignore
}
const author = content('.bbs-user-info-name, .bbs-user-wrapper-content-name-span').text();
const pubDateString = content('.second-line-user-info span:not([class])').text();
// Possible formats: 10:21, 45分钟前, 09-15 19:57
const currentYear = new Date().getFullYear();
const currentDate = new Date();
const monthDayTimePattern = /^\d{2}-\d{2} \d{2}:\d{2}$/;
const timeOnlyPattern = /^\d{1,2}:\d{2}$/;
let processedDateString = pubDateString;
if (monthDayTimePattern.test(pubDateString)) {
processedDateString = `${currentYear}-${pubDateString}`;
} else if (timeOnlyPattern.test(pubDateString)) {
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
const day = String(currentDate.getDate()).padStart(2, '0');
processedDateString = `${currentYear}-${month}-${day} ${pubDateString}`;
}
const pubDate = [item.pubDate, timezone(parseDate(processedDateString), 8), timezone(parseRelativeDate(pubDateString), 8)].find((d) => d instanceof Date && !Number.isNaN(d.getTime()));
const categories = content('.basketballTobbs_tag > a, .tag-player-team')
.toArray()
.map((c) => content(c).text())
.filter(Boolean);
content('.basketballTobbs_tag').remove();
content('.hupu-img').each((_, el) => {
const imgSrc = content(el).attr('data-gif') || content(el).attr('data-origin') || content(el).attr('src');
content(el).parent().html(`<img src="${imgSrc}">`);
});
// 分别获取内容元素
const descriptionParts: string[] = [];
// 获取主要内容
const mainContent = content('#bbs-thread-content, .bbs-content-font').html();
if (mainContent) {
descriptionParts.push(mainContent);
}
// 单独处理视频部分
const videoWrapper = content('.header-video-wrapper');
if (videoWrapper.length > 0) {
const videoElement = videoWrapper.find('video');
if (videoElement.length > 0) {
const videoHtml = videoElement.prop('outerHTML');
if (videoHtml) {
descriptionParts.push(videoHtml);
}
}
}
let description = descriptionParts.length > 0 ? descriptionParts.join('') : undefined;
// 如果有 matchId获取比赛数据
if (matchId) {
const gameStatusHtml = await getGameStatus(matchId);
if (gameStatusHtml) {
description = (description ?? '') + gameStatusHtml;
}
}
return {
...item,
author,
category: categories.length > 0 ? categories : item.category,
description,
pubDate,
};
} catch {
// no-empty
return item;
}
});
}