koishi-plugin-oi-contest-sniffer
Version:
A Koishi plugin that can collect recent OI contests
332 lines (330 loc) • 13.3 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
var __export = (target, all) => {
for (var name2 in all)
__defProp(target, name2, { get: all[name2], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
Config: () => Config,
apply: () => apply,
name: () => name
});
module.exports = __toCommonJS(src_exports);
var import_koishi = require("koishi");
var cheerio = __toESM(require("cheerio"));
var name = "oi-contest-sniffer";
var Config = import_koishi.Schema.object({
defaultMaxContests: import_koishi.Schema.number().min(1).max(30).default(5).description("默认返回比赛数量"),
startSearchFrom: import_koishi.Schema.number().min(-15).max(15).default(2).description("从( )天前存在的比赛开始查询(不查询太过久远的比赛),可以取非正数。这个值会影响到 -s ended 的查找范围"),
timeout: import_koishi.Schema.number().min(1e3).max(6e4).default(1e4).description("比赛数据请求超时阈值(单位毫秒)"),
greetings: import_koishi.Schema.array(String).default([
"还在打OI哦,休息一下吧 ♪(´▽`)",
"今日宜:AK",
"ο(=•ω<=)ρ⌒☆",
"想打比赛吗?那就来吧!(o゜▽゜)o☆",
"今天你开long long了吗 ( •̀ ω •́ )",
"唉你们OIer好可怕 O_O"
]).description("自定义问候语列表,每次随机选择一条显示在查询结果顶部"),
platformAliases: import_koishi.Schema.dict(String).default({
"codeforces": "Codeforces",
"cf": "Codeforces",
"atcoder": "AtCoder",
"at": "AtCoder",
"ac": "AtCoder",
"lg": "Luogu",
"洛谷": "Luogu",
"luogu": "Luogu",
"LeetCode": "obsolete_do_not_use_it"
}).description("平台别名设置,在指定 -p 参数时可简化平台名称输入").role("table"),
statusText: import_koishi.Schema.object({
upcoming: import_koishi.Schema.string().default("还没开始 (●' - '●)").description("未开始比赛的显示文本"),
coding: import_koishi.Schema.string().default("正在火热进行 OwO").description("进行中比赛的显示文本"),
ended: import_koishi.Schema.string().default("结束嘞 o_o ....").description("已结束比赛的显示文本")
}).description("比赛状态显示文本")
});
function apply(ctx, config) {
ctx.command("oi", "获取近期的 OI 线上赛事的日程").usage("查询近期的 OI 线上赛事,支持多种筛选条件").example("查询近期所有比赛(默认):\n oi").example("查询在今天举办的,已经结束的所有比赛:\n oi -d today -s ended").example("查询 2025 年 1 月 1 日在 Codeforces 举办的比赛,只输出在查询时刻尚未开始的前 3 场比赛:\n oi -p cf -d 2025-01-01 -s upcoming -n 3").option("platform", "-p <platform> 筛选比赛平台,可用字段参见 platformAliases 设置").option("phase", '-s <phase> 筛选比赛阶段 (支持 "upcoming", "coding", "ended" 三种参数)').option("count", "-n <count> 限制一次性输出的比赛总数").option("date", "-d <date> 查询最近指定日期的比赛(格式:YYYY-MM-DD,比如 2025-01-01,只能输入最近的日期)").action(async ({ session, options }) => {
try {
const contests = await fetchContests(ctx, config);
const processed = processContests(contests, options, config);
if (processed.length === 0) {
return "嗯?这里没有找到符合条件的比赛 O_O";
}
return generateOutput(processed, config);
} catch (error) {
console.error("Error occurs when fetching contests :(", error);
return `出错了(T_T) 请稍后再试 (ง •_•)ง`;
}
});
}
__name(apply, "apply");
async function fetchContests(ctx, config) {
const [cfContests, atcoderContests, luoguContests] = await Promise.all([
getCodeforcesContests(ctx, config),
getAtcoderContests(ctx, config),
getLuoguContests(ctx, config)
]);
const allContests = [
...cfContests.map((c) => ({ ...c, platform: "Codeforces" })),
...atcoderContests.map((c) => ({ ...c, platform: "AtCoder" })),
...luoguContests.map((c) => ({ ...c, platform: "Luogu" }))
];
const dayStart = Math.floor(Date.now() / 1e3) - config.startSearchFrom * 86400;
return allContests.filter((contest) => contest.startTime + contest.duration >= dayStart);
}
__name(fetchContests, "fetchContests");
function processContests(contests, options, config) {
let processed = [...contests];
if (options.platform) {
const inputPlatform = options.platform.toLowerCase();
let matchedPlatform = null;
for (const [alias, platform] of Object.entries(config.platformAliases)) {
if (alias.toLowerCase() === inputPlatform) {
matchedPlatform = platform;
break;
}
}
if (!matchedPlatform) {
for (const platform of Object.values(config.platformAliases)) {
if (platform.toLowerCase() === inputPlatform) {
matchedPlatform = platform;
break;
}
}
}
if (matchedPlatform) {
processed = processed.filter((c) => c.platform === matchedPlatform);
} else {
return [];
}
}
if (options.phase) {
processed = processed.filter(
(c) => c.phase.toLowerCase().includes(options.phase.toLowerCase())
);
}
if (options.date) {
let targetDate = null;
try {
if (options.date.toLowerCase() === "today") {
targetDate = /* @__PURE__ */ new Date();
} else {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (dateRegex.test(options.date)) {
targetDate = new Date(options.date);
if (isNaN(targetDate.getTime())) {
targetDate = null;
}
}
}
if (targetDate) {
processed = processed.filter((contest) => {
if (!contest.startTime) return false;
const contestDate = new Date(contest.startTime * 1e3);
return contestDate.getFullYear() === targetDate.getFullYear() && contestDate.getMonth() === targetDate.getMonth() && contestDate.getDate() === targetDate.getDate();
});
}
} catch (e) {
console.log("Invalid date format! -d option ignored!");
}
}
processed.sort((a, b) => {
if (a.startTime && b.startTime) return a.startTime - b.startTime;
if (a.phase.includes("coding")) return -1;
if (b.phase.includes("coding")) return 1;
if (a.phase.includes("ended")) return 1;
if (b.phase.includes("ended")) return -1;
return 0;
});
return processed.slice(0, options.count || config.defaultMaxContests);
}
__name(processContests, "processContests");
function generateOutput(contests, config) {
const statusTexts = config.statusText || {
upcoming: "还没开始 (●' - '●)",
coding: "正在火热进行 OwO",
ended: "结束嘞 o_o ...."
};
let output = config.greetings[Math.floor(Math.random() * config.greetings.length)] + "\n-------------\n";
contests.forEach((contest) => {
output += `比赛平台: ${contest.platform}
`;
output += `比赛名: ${contest.name}
`;
if (contest.startTime) {
const startTime = new Date(contest.startTime * 1e3).toLocaleString("zh-CN");
output += `开始时间: ${startTime}
`;
}
if (contest.duration) {
const duration = contest.duration / 60;
output += `比赛时长: ${duration.toFixed(0)}min
`;
}
let status = "";
if (contest.phase.includes("upcoming")) {
status = statusTexts.upcoming;
} else if (contest.phase.includes("coding")) {
status = statusTexts.coding;
} else if (contest.phase.includes("ended")) {
status = statusTexts.ended;
}
output += `比赛状态: ${status}
`;
if (contest.url) {
output += `直达赛场: ${contest.url}
`;
}
output += "------------\n";
});
return output;
}
__name(generateOutput, "generateOutput");
async function getCodeforcesContests(ctx, config) {
try {
const response = await ctx.http.get("https://codeforces.com/api/contest.list", {
params: { gym: false },
timeout: config.timeout,
headers: {
"User-Agent": "Mozilla/5.0",
"Accept": "application/json"
}
});
if (!response || response.status !== "OK" || !Array.isArray(response.result)) {
console.error("Codeforces API Structure mismatched:", response?.status || "No response");
return [];
}
const now = Math.floor(Date.now() / 1e3);
return response.result.map((contest) => {
const startTime = contest.startTimeSeconds;
const endTime = startTime + contest.durationSeconds;
let phase = "upcoming";
if (now > endTime) phase = "ended";
else if (now > startTime) phase = "coding";
return {
name: contest.name,
startTime,
duration: contest.durationSeconds,
phase,
url: `https://codeforces.com/contests/${contest.id}`
};
});
} catch (error) {
console.error("Error occurs when fetching Codeforces contests :(", error);
return [];
}
}
__name(getCodeforcesContests, "getCodeforcesContests");
async function getAtcoderContests(ctx, config) {
try {
const html = await ctx.http.get("https://atcoder.jp/contests", {
timeout: config.timeout,
headers: {
"User-Agent": "Mozilla/5.0"
}
});
const $ = cheerio.load(html);
const contests = [];
const now = Math.floor(Date.now() / 1e3);
const parseDuration = /* @__PURE__ */ __name((text) => {
const [hours, minutes] = text.split(":").map(Number);
return hours * 3600 + minutes * 60;
}, "parseDuration");
$(".table-default tbody tr").each((i, el) => {
const row = $(el);
const startTimeText = row.find("td:nth-child(1)").text().trim();
const contestLink = row.find("td:nth-child(2) a").attr("href");
const contestName = row.find("td:nth-child(2) a").text().trim();
const durationText = row.find("td:nth-child(3)").text().trim();
if (!startTimeText || !contestLink) return;
const startTime = new Date(startTimeText).getTime() / 1e3;
const duration = parseDuration(durationText);
const endTime = startTime + duration;
let phase;
if (now < startTime) {
phase = "upcoming";
} else if (now < endTime) {
phase = "coding";
} else {
phase = "ended";
}
contests.push({
name: contestName,
startTime,
duration,
phase,
url: `https://atcoder.jp${contestLink}`
});
});
return contests;
} catch (error) {
console.error("Error occurs when fetching AtCoder contests :(", error);
return [];
}
}
__name(getAtcoderContests, "getAtcoderContests");
async function getLuoguContests(ctx, config) {
try {
const response = await ctx.http.get("https://www.luogu.com.cn/contest/list?_contentOnly=1", {
timeout: config.timeout,
headers: {
"User-Agent": "Mozilla/5.0",
"X-Requested-With": "XMLHttpRequest",
"Referer": "https://www.luogu.com.cn/contest/list"
}
});
if (!response?.currentData?.contests?.result) {
console.error("Luogu API Structure mismatched", response);
return [];
}
const now = Math.floor(Date.now() / 1e3);
return response.currentData.contests.result.map((contest) => {
const startTime = contest.startTime;
const endTime = contest.endTime;
let phase = "upcoming";
if (now > endTime) phase = "ended";
else if (now > startTime) phase = "coding";
return {
name: contest.name,
startTime,
duration: endTime - startTime,
phase,
url: `https://www.luogu.com.cn/contest/${contest.id}`
};
});
} catch (error) {
console.error("Error occurs when fetching Luogu contests :(", error);
return [];
}
}
__name(getLuoguContests, "getLuoguContests");
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Config,
apply,
name
});