koishi-plugin-oi-contest-sniffer
Version:
A Koishi plugin that can collect recent OI contests
281 lines (274 loc) • 11.4 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,
usage: () => usage
});
module.exports = __toCommonJS(src_exports);
var import_koishi = require("koishi");
var cheerio = __toESM(require("cheerio"));
var name = "oi-contest-sniffer";
var usage = `本插件用于获取各大 OJ 平台的近期比赛信息,目前支持 Codeforces、AtCoder、洛谷。
在群聊中输入 \`oi\` 命令即可查询比赛,支持以下选项:
- \`-p <平台>\`:筛选平台(参考别名设置)
- \`-s <阶段>\`:筛选比赛阶段(\`upcoming\`/\`coding\`/\`ended\`)
- \`-n <数量>\`:指定输出的比赛个数
- \`-d <日期>\`:指定日期(格式:YYYY-MM-DD 或 today)
## 使用示例:
| 命令 | 说明 |
|:-----|:-----|
| \`oi -p cf -s upcoming -n 3\` | 查询未来 3 场 Codeforces 比赛 |
| \`oi -d today -s coding\` | 查询今天正在进行的比赛 |
`;
var Config = import_koishi.Schema.object({
defaultMaxContests: import_koishi.Schema.number().min(1).max(30).default(5).description("不指定 -n 时,默认返回的比赛数量"),
startSearchFrom: import_koishi.Schema.number().min(-15).max(15).default(0).description(`从( )天前存在的比赛开始查询<br>- 默认为 0 表示只查询今天及之后的比赛`),
timeout: import_koishi.Schema.number().min(1e3).max(6e4).default(1e4).description("请求超时阈值(毫秒)"),
platformAliases: import_koishi.Schema.dict(String).default({
"codeforces": "Codeforces",
"cf": "Codeforces",
"atcoder": "AtCoder",
"at": "AtCoder",
"ac": "AtCoder",
"lg": "Luogu",
"洛谷": "Luogu",
"luogu": "Luogu"
}).description("平台别名设置<br>适用于 -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("已结束比赛的显示文本"),
greetings: import_koishi.Schema.array(String).default([
"还在打OI哦,休息一下吧 ♪(´▽`)",
"今日宜:AK",
"ο(=•ω<=)ρ⌒☆"
]).description("问候语列表<br>(会出现在回复消息的第一行)")
}).description("显示文本")
});
function apply(ctx, config) {
ctx.command("oi", "获取近期的 OI 线上赛事的日程").usage("查询近期的 OI 线上赛事,支持多种筛选条件").example("oi").example("oi -d today -s ended").example("oi -p cf -d 2025-01-01 -s upcoming -n 3").option("platform", "-p <platform> 筛选比赛平台").option("phase", "-s <phase> 筛选比赛阶段 (upcoming/coding/ended)").option("count", "-n <count> 指定输出比赛的个数").option("date", "-d <date> 指定日期 (YYYY-MM-DD 或 today)").action(async ({ session, options }) => {
try {
const contests = await fetchAllContests(ctx, config);
const processed = filterAndSortContests(contests, options, config);
return processed.length === 0 ? "嗯?这里没有找到符合条件的比赛 O_O" : formatOutput(processed, config);
} catch (error) {
console.error("Failed to fetch contests:", error);
return "出错了(T_T) 请稍后再试 (ง •_•)ง";
}
});
}
__name(apply, "apply");
async function fetchAllContests(ctx, config) {
const fetchers = [
{ fetch: getCodeforcesContests, platform: "Codeforces" },
{ fetch: getAtcoderContests, platform: "AtCoder" },
{ fetch: getLuoguContests, platform: "Luogu" }
];
const results = await Promise.allSettled(
fetchers.map((f) => f.fetch(ctx, config))
);
const allContests = results.flatMap((result, i) => {
if (result.status === "fulfilled") {
return result.value.map((c) => ({ ...c, platform: fetchers[i].platform }));
}
console.warn(`Failed to fetch from ${fetchers[i].platform}`);
return [];
});
const minTime = Math.floor(Date.now() / 1e3) - config.startSearchFrom * 86400;
return allContests.filter((c) => c.startTime + c.duration >= minTime);
}
__name(fetchAllContests, "fetchAllContests");
function filterAndSortContests(contests, options, config) {
let result = [...contests];
if (options.platform) {
const platform = resolvePlatformAlias(options.platform, config);
if (!platform) return [];
result = result.filter((c) => c.platform === platform);
}
if (options.phase) {
result = result.filter((c) => c.phase === options.phase);
}
if (options.date) {
const targetDate = parseDate(options.date);
if (targetDate) {
const dayStart = Math.floor(targetDate.getTime() / 1e3);
const dayEnd = dayStart + 86400;
result = result.filter(
(c) => c.startTime < dayEnd && c.startTime + c.duration > dayStart
);
}
}
result.sort((a, b) => a.startTime - b.startTime);
return result.slice(0, options.count || config.defaultMaxContests);
}
__name(filterAndSortContests, "filterAndSortContests");
function resolvePlatformAlias(input, config) {
const lowerInput = input.toLowerCase();
for (const [alias, platform2] of Object.entries(config.platformAliases)) {
if (alias.toLowerCase() === lowerInput) return platform2;
}
const platform = Object.values(config.platformAliases).find(
(p) => p.toLowerCase() === lowerInput
);
return platform || null;
}
__name(resolvePlatformAlias, "resolvePlatformAlias");
function parseDate(dateStr) {
if (dateStr.toLowerCase() === "today") return /* @__PURE__ */ new Date();
const match = /^\d{4}-\d{2}-\d{2}$/.test(dateStr);
if (!match) return null;
const date = new Date(dateStr);
return isNaN(date.getTime()) ? null : date;
}
__name(parseDate, "parseDate");
function formatOutput(contests, config) {
const greeting = config.statusText.greetings[Math.floor(Math.random() * config.statusText.greetings.length)];
const statusMap = {
upcoming: config.statusText.upcoming,
coding: config.statusText.coding,
ended: config.statusText.ended
};
let output = greeting + "\n" + "-".repeat(13) + "\n";
contests.forEach((c) => {
output += `比赛平台: ${c.platform}
`;
output += `比赛名: ${c.name}
`;
output += `开始时间: ${new Date(c.startTime * 1e3).toLocaleString("zh-CN")}
`;
output += `比赛时长: ${Math.round(c.duration / 60)}min
`;
output += `比赛状态: ${statusMap[c.phase]}
`;
output += `直达赛场: ${c.url}
`;
output += "-".repeat(13) + "\n";
});
return output;
}
__name(formatOutput, "formatOutput");
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" }
});
if (response?.status !== "OK" || !Array.isArray(response.result)) return [];
const now = Math.floor(Date.now() / 1e3);
return response.result.map((c) => ({
name: c.name,
startTime: c.startTimeSeconds,
duration: c.durationSeconds,
phase: getPhase(now, c.startTimeSeconds, c.startTimeSeconds + c.durationSeconds),
url: `https://codeforces.com/contests/${c.id}`
}));
} catch (e) {
console.warn("Codeforces fetch failed:", e);
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);
$(".table-default tbody tr").each((_, el) => {
const cells = $(el).find("td");
const startTimeText = cells.eq(0).text().trim();
const contestLink = cells.eq(1).find("a").attr("href");
const contestName = cells.eq(1).find("a").text().trim();
const durationText = cells.eq(2).text().trim();
if (!startTimeText || !contestLink) return;
const startTime = Math.floor(new Date(startTimeText).getTime() / 1e3);
const [h, m] = durationText.split(":").map(Number);
const duration = (h || 0) * 3600 + (m || 0) * 60;
const endTime = startTime + duration;
contests.push({
name: contestName,
startTime,
duration,
phase: getPhase(now, startTime, endTime),
url: `https://atcoder.jp${contestLink}`
});
});
return contests;
} catch (e) {
console.warn("AtCoder fetch failed:", e);
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"
}
});
const contests = response?.currentData?.contests?.result || [];
const now = Math.floor(Date.now() / 1e3);
return contests.map((c) => ({
name: c.name,
startTime: c.startTime,
duration: c.endTime - c.startTime,
phase: getPhase(now, c.startTime, c.endTime),
url: `https://www.luogu.com.cn/contest/${c.id}`
}));
} catch (e) {
console.warn("Luogu fetch failed:", e);
return [];
}
}
__name(getLuoguContests, "getLuoguContests");
function getPhase(now, startTime, endTime) {
if (now < startTime) return "upcoming";
if (now < endTime) return "coding";
return "ended";
}
__name(getPhase, "getPhase");
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Config,
apply,
name,
usage
});