refactor(service, routes, types): extract match summary and link to club

- Extract club information (clubId, clubName) from event page in `getEventInfo`.
- Update `getMatchDetail` to return both `detail` and `summary`.
- Introduce `MatchSummary` interface in `src/types/index.ts`.
- Update `EventPage` to display the club name and link to the club page.
- Adjust `EventSubscribeService` and loaders to handle the new return structure.
- Clean up test files and mock data loading logic.
This commit is contained in:
kyuuseiryuu 2026-03-25 23:20:33 +09:00
parent 3cd47a1b4d
commit 4453dd6430
9 changed files with 49 additions and 57 deletions

View File

@ -2,7 +2,7 @@ import { test, expect } from 'bun:test';
import { KaiqiuService } from '../src/services/KaiqiuService';
test('login success', async () => {
const { html, ...result } = await KaiqiuService.login('', '');
const { ...result } = await KaiqiuService.login('', '');
console.debug(result);
expect(result.cookies).toBeDefined();
});

View File

@ -6,7 +6,7 @@ test("Test find 东华 club", async () => {
console.debug(result);
expect(result.clubs.length).toBeGreaterThan(0);
expect(result.total).toBeGreaterThan(result.clubs.length - 1);
const clubId = result.clubs[0]?.clubId ?? '';
const clubId = result.clubs[0]?.id ?? '';
const club = await KaiqiuService.getClubInfo(clubId);
console.debug(club);
expect(club).toBeDefined();

View File

@ -1,18 +0,0 @@
import { expect, test } from 'bun:test';
import path from 'path';
import fs from 'fs';
import { fetchEventContentHTML, fetchEventListHTML } from '../src/utils/';
test('load html', async () => {
const saveTo = path.resolve(__dirname, 'data', 'view-event.html');
const html = await fetchEventListHTML(`47`);
fs.writeFileSync(saveTo, html?? '');
expect(html?.length).not.toBe(0);
});
test('load html', async () => {
const saveTo = path.resolve(__dirname, 'data', 'view-event-content.html');
const html = await fetchEventContentHTML('167684');
fs.writeFileSync(saveTo, html?? '');
expect(html?.length).not.toBe(0);
});

View File

@ -1,28 +1,5 @@
import fs from 'fs';
import { expect, test } from 'bun:test';
import path from 'path';
import { parseEventInfo, parseEventList, sneckGroup } from '../src/utils/';
const matchId = '167684';
const item_id = '7098063';
test('event list not empty', () => {
const html = fs.readFileSync(path.resolve(__dirname, 'data', 'view-event.html')).toString();
const list = parseEventList(html);
expect(list.length).toBe(10);
console.log(list[0]?.matchId);
});
test('event content not empty', () => {
const html = fs.readFileSync(
path.resolve(__dirname, 'data', 'view-event-content.html')
).toString();
const { itemId, players } = parseEventInfo(html);
expect(itemId).toBe(item_id);
expect(players.length).toBeGreaterThan(0);
console.log(players);
expect(players[0]?.uid).not.toBe('');
});
import { sneckGroup } from '../src/utils/common';
test("group", () => {
const group = sneckGroup(48, 6);

View File

@ -1,19 +1,22 @@
import { useLoaderData } from "react-router";
import { GamePanel } from "../components/GamePanel";
import type { BasePlayer, MatchInfo, XCXMember } from "../types";
import { Typography } from "antd";
import type { BasePlayer, MatchInfo, MatchSummary, XCXMember } from "../types";
import { Button, Typography } from "antd";
import { useMemo } from "react";
import { useTitle } from "ahooks";
import { TeamOutlined } from "@ant-design/icons";
export default function EventPage() {
const {
info: game,
members,
uidScore,
summary,
} = useLoaderData<{
uidScore: Map<string, string>,
info: MatchInfo,
members: XCXMember[],
summary: MatchSummary,
}>();
const map = useMemo(() => Object.fromEntries(members.map(e => [e.uid, e])), [members]);
const players = useMemo(() => {
@ -35,7 +38,8 @@ export default function EventPage() {
useTitle(game.title, { restoreOnUnmount: true });
return (
<div className="app">
<Typography.Title>{game.title}</Typography.Title>
<Typography.Title level={3}>{game.title}</Typography.Title>
<Button type="link" onClick={() => open(`/club/${summary.clubId}`)} icon={<TeamOutlined />}>{summary.clubName}</Button>
<GamePanel members={basePlayers} title={game.title} players={players} />
</div>
);

View File

@ -1,7 +1,7 @@
import { createBrowserRouter } from "react-router";
import ProfilePage from "./page/ProfilePage";
import EventPage from "./page/EventPage";
import type { MatchInfo, XCXMember } from "./types";
import type { MatchInfo, MatchSummary, XCXMember } from "./types";
import { FindUserPage } from "./page/FindUserPage";
import { FavePlayersPage } from "./page/FavPlayersPage";
import { UserCenter } from "./page/UserCenter";
@ -41,7 +41,12 @@ export const route = createBrowserRouter([
{
path: '/event/:matchId',
loader: async ({ params }) => {
const info: MatchInfo = await (await fetch(`/api/match/${params.matchId}`)).json();
const { detail: info, summary } = await fetch(`/api/match/${params.matchId}`)
.then(res => res.json())
.then(data => data as {
detail: MatchInfo,
summary: MatchSummary,
});
const members = info.itemId
? await (await fetch(`/api/match/${params.matchId}/${info.itemId}`)).json()
: info.players.map((e, i) => ({
@ -56,7 +61,7 @@ export const route = createBrowserRouter([
method: "POST",
body: JSON.stringify({ uids }),
}).then(res => res.json()).catch(() => ({}));
return { info, members, uidScore: new Map(Object.entries(uidScore)) };
return { info, members, uidScore: new Map(Object.entries(uidScore)), summary };
},
Component: EventPage,
HydrateFallback: () => <HydrateFallback />

View File

@ -66,7 +66,7 @@ export class EventSubscribeService {
});
const details: EventDetail[] = [];
for (const e of beforeEvents) {
const result = await KaiqiuService.getMatchDetail(e.eventId, true)
const { detail: result } = await KaiqiuService.getMatchDetail(e.eventId, true)
console.debug('Get match detail: %s - %s, url: %s', e.title, e.eventId, `https://tt.ksr.la/event/${e.eventId}`)
details.push(result);
}

View File

@ -1,6 +1,6 @@
import * as cheerio from "cheerio";
import { htmlRequestHeaders, redis } from "../utils/server";
import type { ClubInfo, EventDetail, IEventInfo, Player } from "../types";
import type { ClubInfo, EventDetail, IEventInfo, MatchSummary, Player } from "../types";
import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
@ -60,7 +60,7 @@ export class KaiqiuService {
return { id: clubId, name, img, article, geo };
}
static async #fetchEventListHTML(clubId: string, page: number) {
static async fetchEventListHTML(clubId: string, page: number) {
const url = `https://kaiqiuwang.cc/home/space-0-do-mtag-tagid-${clubId}-view-event-page-${page}.html`;
const resp = await fetch(url, { headers: htmlRequestHeaders });
return resp.text();
@ -73,7 +73,7 @@ export class KaiqiuService {
};
let html = await redis.get(`my-kaiqiuwang:events:${clubId}:${page}`);
if (!html || html.includes('连接超时') || force) {
html = await this.#fetchEventListHTML(clubId, page);
html = await this.fetchEventListHTML(clubId, page);
await redis.setex(`my-kaiqiuwang:events:${clubId}:${page}`, 60 * 3, html);
}
const data = await this.#parseEventList(html);
@ -133,7 +133,7 @@ export class KaiqiuService {
return { lng, lat };
}
public static async getEventInfo(eventId: string, force?: boolean) {
public static async getEventInfo(eventId: string, force?: boolean): Promise<MatchSummary> {
// https://kaiqiuwang.cc/home/space-event-id-175775.html
const key = `my-kaiqiuwang:event-info:${eventId}`;
const eventURL = `${this.#baseURL}/home/space-event-id-${eventId}.html`;
@ -150,6 +150,10 @@ export class KaiqiuService {
const title = $('#mainarea > h2 > a:nth-child(3)').text().trim();
const nums = $('#content > div:nth-child(1) > div > div.event_content > ul > li:nth-child(2)').text();
const see = /(?<see>\d+)\b/.exec($('#content > div:nth-child(1) > div > div.event_content > ul > li:nth-child(1)').text())?.groups?.see ?? '';
const club = $('#sidebar .sidebox > p > a');
const clubLink = club.attr('href') ?? '';
const clubName = club.text() ?? '';
const clubId = /-(?<clubId>\d+).html$/.exec(clubLink)?.groups?.clubId ?? '';
const location = $('#content > div:nth-child(1) > div > div.event_content > dl > dd:nth-child(6) > a')
.text().split(' ')?.[1]?.trim() ?? '';
const startTime = dayjs.tz(startDate || new Date(), 'Asia/Tokyo');
@ -160,9 +164,12 @@ export class KaiqiuService {
if (isFinished) {
await redis.set(key, eventPage);
}
console.debug('clubId', clubId);
return {
title,
eventId,
clubId,
clubName,
startDate,
url: eventURL,
isProcessing,
@ -177,16 +184,19 @@ export class KaiqiuService {
const url = `${this.#baseURL}/home/space.php?do=event&id=${eventId}&view=member&status=2`;
const key = `my-kaiqiuwang:match-detail:${eventId}`;
let html = await redis.get(key) ?? '';
const info = await this.getEventInfo(eventId);
if (!html || html.includes('连接超时') || force) {
html = await fetch(url, { headers: htmlRequestHeaders }).then(res => res.text() || '');
const info = await this.getEventInfo(eventId)
if (info.isFinished) {
await redis.set(key, html);
} else {
await redis.setex(key, 60 * 5, html);
}
}
return this.parseEventInfo(html, eventId);
return {
detail: this.parseEventInfo(html, eventId),
summary: info,
};
}
private static parseEventInfo(html: string, eventId: string): EventDetail {

View File

@ -22,6 +22,20 @@ export interface MatchInfo {
players: Player[];
}
export interface MatchSummary {
title: string;
eventId: string;
clubName: string;
clubId: string;
startDate: string;
url: string;
isProcessing: boolean;
isFinished: boolean;
location: string;
nums: string;
see: string;
}
export interface BasePlayer {
uid: string;
name: string;