import * as cheerio from "cheerio";
import { htmlRequestHeaders, redis } from "../utils/server";
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';
dayjs.extend(utc);
dayjs.extend(timezone);
export class KaiqiuService {
static #baseURL = 'https://kaiqiuwang.cc';
public static async findClub(name: string, page = 1, normalClub?: boolean, province?: string, city?: string) {
const searchKey = encodeURIComponent(name);
const url = `${this.#baseURL}/home/space.php?province=${province}&city=${city}&searchkey=${searchKey}&searchsubmit=%E6%90%9C%E7%B4%A2&searchmode=1&do=mtag&view=hot&${normalClub ? 'page2': 'page'}=${page}`;
const html = await fetch(url, {
headers: htmlRequestHeaders,
}).then(res => res.text());
return this.parseSearchClubPage(html, Boolean(normalClub));
}
private static parseSearchClubPage(html: string, normal: boolean): {
clubs: ClubInfo[];
total: number;
} {
const $ = cheerio.load(html);
const infos = $(`#content > div:nth-of-type(${normal ? 2 : 1}) td[width="250"]`).toArray();
const imgs = $(`#content > div:nth-of-type(${normal ? 2 : 1}) td[width="80"]`).toArray();
const parseInfo = (info: typeof infos[number], index: number): ClubInfo => {
const name = $(info).find('a').text().trim();
const url = $(info).find('a').attr('href') ?? '';
const clubId = /-(?\d+).html$/.exec(url)?.groups?.id?.toString() ?? '';
const members = parseInt($(info).find('span.num').text().trim());
const area = $(info).find('div.gray:nth-of-type(1)').text().trim();
const src = $(imgs[index]).find('.threadimg60 img').attr('src') ?? '';
const img = src.startsWith('http') ? src : 'https://kaiqiuwang.cc/home/image/nologo.jpg';
return { name, id: clubId, members, area, img, url: `${this.#baseURL}/home/${url}` };
}
const clubs = infos.map((e, i) => parseInfo(e, i));
const total = parseInt($('#content > div:nth-of-type(1) div.page > em').text().trim()) || clubs.length;
return { clubs, total };
}
public static async getClubInfo(clubId: string, force?: boolean) {
if (!clubId) return null;
const url = `${this.#baseURL}/home/space-mtag-tagid-${clubId}.html`;
const key = `my-kaiqiuwang:club:${clubId}`;
let html = await redis.get(key) ?? '';
if (!html || html.includes('连接超时') || force) {
html = await fetch(url, { headers: htmlRequestHeaders }).then(res => res.text());
await redis.setex(key, 60 * 60 * 24, html);
}
const $ = cheerio.load(html);
const name = $('h2.title a:nth-of-type(2)').text().trim();
const src = $('#space_avatar img').attr('src') ?? '';
const img = src.startsWith('http') ? src : 'https://kaiqiuwang.cc/home/image/nologo.jpg';
const article = $('.article').text().trim();
const geo = await this.#getClubLocation(clubId);
return { id: clubId, name, img, article, geo };
}
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, force?: boolean) {
if (!clubId) return {
data: [],
total: 0,
};
let html = await redis.get(`my-kaiqiuwang:events:${clubId}:${page}`);
if (!html || html.includes('连接超时') || force) {
html = await this.fetchEventListHTML(clubId, page);
await redis.setex(`my-kaiqiuwang:events:${clubId}:${page}`, 60 * 3, html);
}
const data = await this.#parseEventList(html);
return data;
}
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,
isFinished,
isProcessing,
location,
nums,
see,
} = await this.getEventInfo(matchId);
const event: IEventInfo = {
title,
info: [`比赛时间:${startDate}`, place, [see, nums].join(' ')],
url: eventURL,
startDate,
matchId,
isFinished,
isProcessing,
location,
}
list.push(event);
}
const total = parseInt($('#mainarea > div.event_list > div > em').text().trim());
return {
data: list,
total: total || list.length,
};
}
static async #getClubLocation(clubId: string, force?: boolean) {
const url = `${this.#baseURL}/home/space-mtag-tagid-${clubId}-view-map.html`;
const key = `my-kaiqiuwang:location:${clubId}`;
let html: string = await redis.get(key) || '';
if (!html || html.includes('连接超时') || force) {
html = await fetch(url, { headers: htmlRequestHeaders }).then(res => res.text());
redis.setex(key, 60 * 60 * 24, html);
}
const $ = cheerio.load(html);
const lng = Number($('#lng').val());
const lat = Number($('#lat').val());
if (!lng && !lat) return null;
return { lng, lat };
}
public static async getEventInfo(eventId: string, force?: boolean): Promise {
// 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`;
let eventPage = await redis.get(key) ?? '';
if (!eventPage || eventPage.includes('连接超时') || force) {
eventPage = await fetch(eventURL, { headers: htmlRequestHeaders }).then(res => res.text() ?? '');
await redis.setex(key, 60 * 3, eventPage);
}
const $ = cheerio.load(eventPage);
const eventContent = $('.event_content').text().replace(/(\r|\n)/g, ',').split(',').filter(Boolean).join(' ');
const { y, M, D, H, m} = /比赛开始:.*?(?\d{4})年(?\d{2})月(?\d{2})日 \w+ (?\d{2}):(?\d{2})/
.exec(eventContent)?.groups ?? {};
const startDate = [y, M, D, H, m].every(Boolean) ? `${y}-${M}-${D} ${H}:${m}` : '0000-00';
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 = /(?\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 = /-(?\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');
const overTime = startTime.add(6, 'hour');
const now = dayjs();
const isProcessing = now.isAfter(startTime) && now.isBefore(overTime);
const isFinished = now.isAfter(overTime);
if (isFinished) {
await redis.set(key, eventPage);
}
console.debug('clubId', clubId);
return {
title,
eventId,
clubId,
clubName,
startDate,
url: eventURL,
isProcessing,
isFinished,
location,
nums,
see: see ? `${see} 次查看` : '',
}
}
public static async getMatchDetail(eventId: string, force?: boolean) {
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() || '');
if (info.isFinished) {
await redis.set(key, html);
} else {
await redis.setex(key, 60 * 5, html);
}
}
return {
detail: this.parseEventInfo(html, eventId),
summary: info,
};
}
private static parseEventInfo(html: string, eventId: string): EventDetail {
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-(?\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,
eventId,
title,
players,
}
}
public static async login(username: string, password: string) {
const loginPageRes = await fetch('https://kaiqiuwang.cc/home/do.php?ac=668g&&ref');
const cookies = loginPageRes.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');
console.debug('init cookies: ', cookies);
let $ = cheerio.load(await loginPageRes.text());
const loginsubmit = $('#loginsubmit').val()?.toString() || '';
const formhash = $('#loginform > input[type=hidden]').val()?.toString() || '';
const cookietime = $('#cookietime').val()?.toString() || '';
const refer = "https://kaiqiuwang.cc/home/do.php?ac=668g&&ref";
const formData = new FormData();
formData.append('loginsubmit', loginsubmit);
formData.append('cookietime', cookietime);
formData.append('formhash', formhash);
formData.append('username', username);
formData.append('password', password);
formData.append('refer', refer);
const loginRes = await fetch("https://kaiqiuwang.cc/home/do.php?ac=668g&&ref", {
"headers": {
"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.9",
"cache-control": "no-cache",
"content-type": "application/x-www-form-urlencoded",
"pragma": "no-cache",
"priority": "u=0, i",
"sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-origin",
"sec-fetch-user": "?1",
"sec-gpc": "1",
"upgrade-insecure-requests": "1",
"cookie": cookies,
"Referer": "https://kaiqiuwang.cc/home/do.php?ac=668g"
},
"body": new URLSearchParams(Object.fromEntries(formData.entries())),
"method": "POST"
});
const PHPSESSID = cookies.split('; ').filter(cookie => cookie.startsWith('PHPSESSID'))[0];
const authCookies = [
PHPSESSID,
loginRes.headers
.getSetCookie()
.map(c => c.split(';')[0])
.filter(cookie => !cookie?.includes('uchome_auth=deleted'))
.join('; ')
].join('; ');
console.debug('auth cookies', authCookies);
const networkPageReq = await fetch("https://kaiqiuwang.cc/home/network.php", {
"headers": {
"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.9",
"cache-control": "no-cache",
"pragma": "no-cache",
"priority": "u=0, i",
"sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"",
"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": authCookies,
},
"method": "GET",
"redirect": 'follow',
});
const html = await networkPageReq.text();
$ = cheerio.load(html);
const success = $('#header_content > div.user_info > div.login_ext').text().includes('欢迎');
const href = $('#header_content > div.user_info > div.login_ext > p > a.loginName').attr('href') || '';
const uid = /\b(?\d+)\.html/.exec(href)?.groups?.uid;
return { uid, success, cookies: authCookies };
}
}