feat(game-selector): implement pagination and caching for event list

- Add 'ics' dependency to package.json and update lockfile.
- Introduce pagination support in `ClubEventList` component (Ant Design Pagination + Spin loading state).
- Refactor `GameSelector` to fetch paginated data (`page` query parameter) and manage page state.
- Update `KaiqiuService` to:
  - Add Redis caching for event lists (1 hour TTL) and individual match details (10 hours TTL).
  - Implement `listClubEvents` method with pagination support (fetching HTML, parsing titles/urls/status, extracting total count).
  - Update `getEventInfo` to utilize cached data.
- Update server routes (`index.tsx`, `routes.tsx`) to handle query parameters and pass paginated responses to the frontend.
- Fix logic in `GroupingPrediction` to set group length when players <= 12.

This change improves performance by reducing initial load times through pagination and caching, preventing UI freezing with large event lists.
This commit is contained in:
kyuuseiryuu 2026-03-13 11:34:23 +09:00
parent b560684dfb
commit d323e1d925
8 changed files with 142 additions and 29 deletions

View File

@ -13,6 +13,7 @@
"antd": "^6.3.2", "antd": "^6.3.2",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"ics": "^3.8.1",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"mariadb": "^3.5.2", "mariadb": "^3.5.2",
"react": "^19", "react": "^19",
@ -317,6 +318,8 @@
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ics": ["ics@3.8.1", "", { "dependencies": { "nanoid": "^3.1.23", "runes2": "^1.1.2", "yup": "^1.2.0" } }, "sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g=="],
"intersection-observer": ["intersection-observer@0.12.2", "", {}, "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg=="], "intersection-observer": ["intersection-observer@0.12.2", "", {}, "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg=="],
"is-mobile": ["is-mobile@5.0.0", "", {}, "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ=="], "is-mobile": ["is-mobile@5.0.0", "", {}, "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ=="],
@ -389,6 +392,8 @@
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"property-expr": ["property-expr@2.0.6", "", {}, "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"quick-lru": ["quick-lru@6.1.2", "", {}, "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ=="], "quick-lru": ["quick-lru@6.1.2", "", {}, "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ=="],
@ -415,6 +420,8 @@
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"runes2": ["runes2@1.1.4", "", {}, "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@ -449,11 +456,15 @@
"throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="], "throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
"tiny-case": ["tiny-case@1.0.3", "", {}, "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"toposort": ["toposort@2.0.2", "", {}, "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="],
"undici": ["undici@7.19.0", "", {}, "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ=="], "undici": ["undici@7.19.0", "", {}, "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ=="],
@ -467,6 +478,8 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"yup": ["yup@1.7.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw=="],
"zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="],
"zustand": ["zustand@5.0.10", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg=="], "zustand": ["zustand@5.0.10", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg=="],
@ -527,6 +540,8 @@
"antd/@rc-component/util": ["@rc-component/util@1.9.0", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-5uW6AfhIigCWeEQDthTozlxiT4Prn6xYQWeO0xokjcaa186OtwPRHBZJ2o0T0FhbjGhZ3vXdbkv0sx3gAYW7Vg=="], "antd/@rc-component/util": ["@rc-component/util@1.9.0", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-5uW6AfhIigCWeEQDthTozlxiT4Prn6xYQWeO0xokjcaa186OtwPRHBZJ2o0T0FhbjGhZ3vXdbkv0sx3gAYW7Vg=="],
"camelcase-keys/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"chevrotain/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "chevrotain/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],

View File

@ -17,6 +17,7 @@
"antd": "^6.3.2", "antd": "^6.3.2",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"ics": "^3.8.1",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"mariadb": "^3.5.2", "mariadb": "^3.5.2",
"react": "^19", "react": "^19",

View File

@ -1,10 +1,16 @@
import { Divider, Empty, Flex, Space, Switch, Typography } from "antd"; import { Divider, Empty, Flex, Pagination, Space, Spin, Switch, Typography } from "antd";
import type { IEventInfo } from "../types"; import type { IEventInfo } from "../types";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { EventCard } from "./EventCard"; import { EventCard } from "./EventCard";
interface Props { interface Props {
events: IEventInfo[]; events: IEventInfo[];
loading?: boolean;
pagination?: {
page: number;
total: number;
onPageChange: (page: number) => void;
};
} }
export const ClubEvenList = (props: Props) => { export const ClubEvenList = (props: Props) => {
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
@ -12,8 +18,13 @@ export const ClubEvenList = (props: Props) => {
if (showAll) return props.events; if (showAll) return props.events;
return props.events.filter(e => !e.isFinished); return props.events.filter(e => !e.isFinished);
}, [showAll, props.events]); }, [showAll, props.events]);
useEffect(() => {
if (!showAll && props.pagination && props.pagination.page > 1) {
props.pagination.onPageChange(1); // Reset page
}
}, [showAll, props]);
return ( return (
<> <Spin spinning={props.loading}>
<Divider> <Divider>
<Space> <Space>
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
@ -21,6 +32,13 @@ export const ClubEvenList = (props: Props) => {
</Space> </Space>
</Divider> </Divider>
<Flex wrap vertical gap={12} justify="center" align="center"> <Flex wrap vertical gap={12} justify="center" align="center">
{showAll && props.pagination ? (
<Pagination
total={props.pagination.total}
current={props.pagination.page}
onChange={props.pagination.onPageChange}
showSizeChanger={false}
/>) : null}
{visibleEvents.length ? visibleEvents.map(e => { {visibleEvents.length ? visibleEvents.map(e => {
return ( return (
<div key={e.matchId} style={{ width: '100%', maxWidth: 600 }}> <div key={e.matchId} style={{ width: '100%', maxWidth: 600 }}>
@ -28,7 +46,14 @@ export const ClubEvenList = (props: Props) => {
</div> </div>
); );
}) : <Empty description="暂无活动" />} }) : <Empty description="暂无活动" />}
{showAll && props.pagination ? (
<Pagination
total={props.pagination.total}
current={props.pagination.page}
onChange={props.pagination.onPageChange}
showSizeChanger={false}
/>) : null}
</Flex> </Flex>
</> </Spin>
); );
} }

View File

@ -2,8 +2,7 @@ import { Flex, Select, Space } from 'antd';
import type React from 'react'; import type React from 'react';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import { clubs } from './clubList'; import { clubs } from './clubList';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import dayjs from 'dayjs';
import type { IEventInfo } from '../../types'; import type { IEventInfo } from '../../types';
import { ClubEvenList } from '../ClubEventList'; import { ClubEvenList } from '../ClubEventList';
@ -12,31 +11,21 @@ interface Props {
export const GameSelector: React.FC<Props> = props => { export const GameSelector: React.FC<Props> = props => {
const [clubId, setClubId] = useState<string>(clubs[0]?.clubId ?? ''); const [clubId, setClubId] = useState<string>(clubs[0]?.clubId ?? '');
const requestEvents = useRequest<IEventInfo[], [string]>( const requestEvents = useRequest<{data: IEventInfo[]; total: number; }, [string, number]>(
async (clubId: string) => { async (clubId: string, page = 1) => {
if (!clubId) return []; if (!clubId) return [];
return (await fetch(`/api/events/${clubId}`)).json() return (await fetch(`/api/events/${clubId}?page=${page}`)).json()
}, { manual: true }); }, { manual: true });
const [showFinished, setShowFinished] = useState(false);
const gameList = useMemo(() => {
const activeList = requestEvents.data?.map(e => ({
...e,
finished: dayjs(e.startDate).isBefore(dayjs()),
}));
return activeList;
}, [requestEvents.data]);
const isEmpty = useMemo(() => {
return (gameList ?? []).filter(e => !e.finished).length === 0
}, [gameList]);
const handleClubChange = useCallback(async (id: string) => { const handleClubChange = useCallback(async (id: string) => {
setClubId(id); setClubId(id);
requestEvents.runAsync(id); requestEvents.runAsync(id, page);
}, []); }, []);
const [page, setPage] = useState(1);
useEffect(() => { useEffect(() => {
const id = setTimeout(() => { const id = setTimeout(() => {
const clubId = clubs?.[0]?.clubId; const clubId = clubs?.[0]?.clubId;
if (!clubId) return; if (!clubId) return;
requestEvents.run(clubId); requestEvents.run(clubId, 1);
}, 100); }, 100);
return () => clearTimeout(id); return () => clearTimeout(id);
}, []); }, []);
@ -52,7 +41,18 @@ export const GameSelector: React.FC<Props> = props => {
onChange={handleClubChange} onChange={handleClubChange}
/> />
</Flex> </Flex>
<ClubEvenList events={requestEvents.data ?? []} /> <ClubEvenList
events={requestEvents.data?.data ?? []}
loading={requestEvents.loading}
pagination={{
total: requestEvents.data?.total ?? 0,
page,
onPageChange: page => {
setPage(page);
requestEvents.runAsync(clubId, page);
}
}}
/>
</Space> </Space>
); );
} }

View File

@ -51,7 +51,7 @@ export const GroupingPrediction: React.FC<Props> = props => {
if (players.length < 48) { if (players.length < 48) {
setMaxPlayerSize(players.length); setMaxPlayerSize(players.length);
} }
if (players.length < 12) { if (players.length <= 12) {
setGroupLen(1); setGroupLen(1);
} }
} }

View File

@ -30,7 +30,8 @@ const server = serve({
}, },
"/api/events/:clubid": { "/api/events/:clubid": {
async GET(req) { async GET(req) {
const data = await listEvent(req.params.clubid); const page = Number(new URL(req.url).searchParams.get('page')) ?? 1;
const data = await KaiqiuService.listClubEvents(req.params.clubid, page);
return Response.json(data); return Response.json(data);
} }
}, },

View File

@ -45,11 +45,11 @@ export const route = createBrowserRouter([
HydrateFallback: () => <HydrateFallback />, HydrateFallback: () => <HydrateFallback />,
loader: async ({ params }) => { loader: async ({ params }) => {
const id = params.id; const id = params.id;
const [info, events] = await Promise.all([ const [info, event] = await Promise.all([
fetch(`/api/club/${id}`).then(r => r.json()), fetch(`/api/club/${id}`).then(r => r.json()),
fetch(`/api/events/${id}`).then(r => r.json()), fetch(`/api/events/${id}`).then(r => r.json()),
]); ]);
return { info, events }; return { info, events: event.data, total: event.total };
}, },
}, },
{ {

View File

@ -1,5 +1,7 @@
import * as cheerio from "cheerio"; import * as cheerio from "cheerio";
import { htmlRequestHeaders } from "../utils/server"; import { htmlRequestHeaders, redis, REDIS_CACHE_HOUR } from "../utils/server";
import type { IEventInfo } from "../types";
import dayjs from "dayjs";
export class KaiqiuService { export class KaiqiuService {
static #baseURL = 'https://kaiqiuwang.cc'; static #baseURL = 'https://kaiqiuwang.cc';
@ -44,4 +46,73 @@ export class KaiqiuService {
return { id: clubId, name, img, article }; return { id: clubId, name, img, article };
} }
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();
}
public static async listClubEvents(clubId: string, page = 1) {
if (!clubId) return {
data: [],
total: 0,
};
const key = `my-kaiqiuwang:club-events:${clubId}:page-${page}`;
let html = await redis.get(key).catch(() => '');
if (!html) {
html = await this.#fetchEventListHTML(clubId, page);
redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html);
}
return await this.#parseEventList(html);
}
static async #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 place = $(block).find('ul li:nth-of-type(2)').text().replace('比赛地点: \\t', '').trim();
const eventPath = $(titleEl).find('a').attr('href') ?? '';
const eventURL = `${this.#baseURL}/home/${eventPath}`;
const matchId = /\S+-(\d+).html$/.exec(eventPath)?.[1] ?? '';
const { startDate } = await this.getEventInfo(matchId);
const event: IEventInfo = {
title,
info: [`比赛时间:${startDate}`, place],
url: eventURL,
startDate,
matchId,
isFinished: dayjs(startDate).isBefore(),
}
list.push(event);
}
const total = parseInt($('#mainarea > div.event_list > div > em').text().trim());
return {
data: list,
total: total || list.length,
};
}
public static async getEventInfo(eventId: string) {
let eventPage = await redis.get(`my-kaiqiuwang:match:${eventId}`) ?? '';
// https://kaiqiuwang.cc/home/space-event-id-175775.html
const eventURL = `${this.#baseURL}/home/space-event-id-${eventId}.html`;
if (!eventPage) {
eventPage = await fetch(eventURL, { headers: htmlRequestHeaders }).then(res => res.text() ?? '');
await redis.setex(`my-kaiqiuwang:match:${eventId}`, 60 * 60 * 10, eventPage)
}
const $ = cheerio.load(eventPage);
const eventContent = $('.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 title = $('#mainarea > h2 > a:nth-child(3)').text().trim();
return {
title,
startDate,
}
}
} }