- **Frontend Refactoring**: Extracted EventCard logic and finished game filtering from GameSelector into a new ClubEventsList component. Removed direct routing logic (handleGameClick, navigate) from App.tsx in favor of router loaders. - **New Feature - Club Detail Page**: Implemented /club/:id route with ClubEventsPage. Added server-side loader to fetch club info and events in parallel via new API endpoints (/api/club/find, /api/club/:id). Created KaiqiuService for direct data fetching. - **API & Types**: Extended IEventInfo with isFinished flag; updated server-side parsing in utils/server.ts. Added ClubInfo type definition. Migrated club search and filtering logic to the server side. - **Dependencies**: Updated antd from 6.2.1 to 6.3.2.
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
import type { IEventInfo, Player } from "../types";
|
|
import * as cheerio from "cheerio";
|
|
import { XCXAPI } from "../services/xcxApi";
|
|
import { BASE_URL } from "./common";
|
|
import { RedisClient } from "bun";
|
|
import dayjs from "dayjs";
|
|
|
|
const REQUIRED_ENVS = [
|
|
process.env.KAIQIUCC_TOKEN,
|
|
process.env.REDIS,
|
|
];
|
|
|
|
console.debug('ENVS: \n%s', REQUIRED_ENVS.join('\n'));
|
|
if (!REQUIRED_ENVS.every(v => !!v)) {
|
|
console.error('Missing required environment variables. Please check your .env');
|
|
process.exit(1);
|
|
}
|
|
export const REDIS_CACHE_HOUR = Number(process.env.REDIS_CACHE_HOUR) || 8;
|
|
|
|
console.debug('Cache hour: %s', REDIS_CACHE_HOUR);
|
|
|
|
export const xcxApi = new XCXAPI(process.env.KAIQIUCC_TOKEN ?? '');
|
|
|
|
export const redis = new RedisClient(process.env.REDIS ?? '');
|
|
|
|
export const htmlRequestHeaders = {
|
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
"accept-language": "zh-CN,zh;q=0.8",
|
|
"cache-control": "max-age=0",
|
|
"priority": "u=0, i",
|
|
"sec-ch-ua": "\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"",
|
|
"sec-ch-ua-mobile": "?0",
|
|
"sec-ch-ua-platform": "\"Windows\"",
|
|
"sec-fetch-dest": "document",
|
|
"sec-fetch-mode": "navigate",
|
|
"sec-fetch-site": "none",
|
|
"sec-fetch-user": "?1",
|
|
"sec-gpc": "1",
|
|
"upgrade-insecure-requests": "1",
|
|
"cookie": "SECKEY_ABVK=oTGgqH4ypGPFVdQ3J9K7PoAOPdZ+8R7CsUzi75gelcg%3D; uchome_sendmail=1"
|
|
}
|
|
|
|
/**
|
|
* @param tagid 俱乐部 ID
|
|
*/
|
|
export async function listEvent(tagid: string): Promise<IEventInfo[]> {
|
|
const key = `my-kaiqiuwang:club-events:${tagid}`;
|
|
let html = await redis.get(key).catch(() => '');
|
|
if (!html) {
|
|
html = await fetchEventListHTML(tagid);
|
|
redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html);
|
|
}
|
|
return await parseEventList(html);
|
|
}
|
|
|
|
/**
|
|
* @param tagid 俱乐部 ID
|
|
* @return HTML
|
|
*/
|
|
export async function fetchEventListHTML(tagid: string) {
|
|
const url = `${BASE_URL}/home/space.php?do=mtag&tagid=${tagid}&view=event`;
|
|
const resp = await fetch(url, { headers: htmlRequestHeaders });
|
|
return resp.text() ?? '';
|
|
}
|
|
|
|
export async function parseEventList(html: string) {
|
|
const $ = cheerio.load(html);
|
|
const blockList = $('div.event_list > ol > li');
|
|
const list: IEventInfo[] = [];
|
|
for (const block of blockList) {
|
|
const titleEl = $(block).find('.event_title');
|
|
const title = titleEl.text();
|
|
const eventPath = $(titleEl).find('a').attr('href') ?? '';
|
|
const place = $(block).find('ul li:nth-of-type(2)').text().replace('比赛地点: \\t', '').trim();
|
|
const eventURL = `${BASE_URL}/home/${eventPath}`;
|
|
const matchId = /\S+-(\d+).html$/.exec(eventPath)?.[1] ?? '';
|
|
let eventPage = await redis.get(`my-kaiqiuwang:match:${matchId}`) ?? '';
|
|
if (!eventPage) {
|
|
eventPage = await fetch(eventURL, { headers: htmlRequestHeaders }).then(res => res.text() ?? '');
|
|
await redis.setex(`my-kaiqiuwang:match:${matchId}`, 60 * 60 * 10, eventPage)
|
|
}
|
|
const $eventPage = cheerio.load(eventPage);
|
|
const eventContent = $eventPage('.event_content').text().replace(/(\r|\n)/g, ',').split(',').filter(Boolean).join(' ');
|
|
const { y, M, D, H, m} = /比赛开始:.*?(?<y>\d{4})年(?<M>\d{2})月(?<D>\d{2})日 \w+ (?<H>\d{2}):(?<m>\d{2})/
|
|
.exec(eventContent)?.groups ?? {};
|
|
const startDate = y ? `${y}-${M}-${D} ${H}:${m}` : '';
|
|
const event: IEventInfo = {
|
|
title,
|
|
info: [`比赛时间:${startDate}`, place],
|
|
url: eventURL,
|
|
startDate,
|
|
matchId,
|
|
isFinished: dayjs(startDate).isBefore(),
|
|
}
|
|
list.push(event);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param matchId 比赛 ID
|
|
* @returns HTML
|
|
*/
|
|
export async function fetchEventContentHTML(matchId: string) {
|
|
const url = `${BASE_URL}/home/space.php?do=event&id=${matchId}&view=member&status=2`;
|
|
const resp = await fetch(url, { headers: htmlRequestHeaders });
|
|
return resp.text() ?? ''
|
|
}
|
|
|
|
export function parseEventInfo(html: string) {
|
|
const $ = cheerio.load(html);
|
|
const title = $('h2.title a').text();
|
|
const itemHref = $('.sub_menu a.active').attr('href') ?? '';
|
|
const itemId = /\S+item_id=(\d+)$/.exec(itemHref)?.[1] ?? '';
|
|
const players: Player[] = [];
|
|
const playersEl = $('.thumb');
|
|
for (const player of playersEl) {
|
|
const img = $(player).find('.image img').attr('src') ?? '';
|
|
const name = $(player).find('h6').text().trim();
|
|
const uid = /space-(?<uid>\d+).html/.exec($(player).find('h6 a').attr('href') ?? '')?.groups?.uid ?? '';
|
|
const info = $(player).find('p:nth-of-type(2)').text().replace(/\s/g, '');
|
|
const score = /^.*?\b(\d+)\b/.exec(info)?.[1] ?? '';
|
|
players.push({ name, avatar: img, score, info, uid });
|
|
}
|
|
return {
|
|
itemId,
|
|
title,
|
|
players,
|
|
}
|
|
}
|
|
|
|
export async function getMatchInfo(matchId: string) {
|
|
const key = `my-kaiqiuwang:match-member:${matchId}`;
|
|
let html = await redis.get(key).catch(() => '');
|
|
if (!html) {
|
|
html = await fetchEventContentHTML(matchId);
|
|
redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html);
|
|
}
|
|
return parseEventInfo(html);
|
|
}
|