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 }; } }