Skip to content

Instantly share code, notes, and snippets.

@morioprog
Last active October 10, 2022 09:10
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save morioprog/c2cde4738678f10e561832ea14fd181b to your computer and use it in GitHub Desktop.
Save morioprog/c2cde4738678f10e561832ea14fd181b to your computer and use it in GitHub Desktop.
競技プログラミングのコンテスト予定を表示するiOSウィジェット(導入方法:https://blog.morio.dev/2021/01/contest_schedule_widget/
// 初期設定
// 1. CLIST(`https://clist.by/`)にログイン
// 2. `https://clist.by/api/v1/doc/`の上部の「show my api-key」を選択
// 3. 出てきたモーダル下部の「Param query」を下のCLIST_APIに貼り付け
const CLIST_API = "/?username=*****&api_key=****************************************";
// contestIds : 表示するコンテストサイトのリスト (CLISTにおけるコンテストサイトのID)
// > `https://clist.by/api/v1/doc/#!/resource/resource_list/`から検索できる
// * codeforces.com : 1
// * atcoder.jp : 93
// * yukicoder.me : 109
// * topcoder.com : 12
const contestIds = [1, 93, 109, 12];
// --------------------------------------------------------------
const apiTemplate = ({resource_id}) => `https://clist.by/api/v1/contest${CLIST_API}&resource__id=${resource_id}&start__gt=${encodeURIComponent(new Date().toISOString())}`;
const FILE_MANAGER = FileManager.local(); // or FileManager.iCloud()
const LASTUPD_FILENAME = "contest_shedule_last_update.txt";
const JSON_FILENAME = "contest_schedule.json";
function saveFile(filename, str) {
const path = FILE_MANAGER.joinPath(FILE_MANAGER.documentsDirectory(), filename);
FILE_MANAGER.writeString(path, str);
}
function loadFile(filename) {
const path = FILE_MANAGER.joinPath(FILE_MANAGER.documentsDirectory(), filename);
if (FILE_MANAGER.fileExists(path)) return FILE_MANAGER.readString(path);
return "";
}
function loadContests() {
const str = loadFile(JSON_FILENAME);
let contests = str === "" ? [] : JSON.parse(str);
// jsonから読み込んだときにすでに始まってるものがあれば弾く
contests = contests.filter(contest => new Date(contest.start + '+0000') >= new Date());
return contests;
}
function date2str(date) {
const dateStr = `${date.getMonth() + 1}/${date.getDate()}`;
const timeStr = `${date.getHours()}:${('0' + date.getMinutes()).slice(-2)}`;
return `${dateStr} ${timeStr}`;
}
// maxLength : 最大何件のコンテストを表示するか (Mediumなら3, Largeなら9にするとちょうどいい)
async function fetchContestSchedule(maxLength) {
let lastUpd = loadFile(LASTUPD_FILENAME);
let contests = [];
// 1時間ごとに更新
if (lastUpd === "" || (new Date() - new Date(lastUpd)) >= (1000 * 60 * 60)) {
// 初期設定をしていない
if (~CLIST_API.indexOf('*')) {
return [false, lastUpd, "Error: ソースコード上部の初期設定を行ってください", contests];
}
try {
for (const id of contestIds) {
const req = new Request(apiTemplate({resource_id: id}));
const contestJson = await req.loadJSON();
contests = contests.concat(await contestJson.objects);
}
} catch (err) {
// とりあえずjsonから読み込む
contests = loadContests();
contests.sort((a, b) => a.start === b.start ? a.event > b.event : a.start > b.start);
return [false, lastUpd, err.toString(), contests.slice(0, maxLength)];
}
lastUpd = new Date().toISOString();
saveFile(JSON_FILENAME, JSON.stringify(contests));
saveFile(LASTUPD_FILENAME, lastUpd);
} else {
// jsonから読み込む
contests = loadContests();
}
contests.sort((a, b) => a.start === b.start ? a.event > b.event : a.start > b.start);
return [true, lastUpd, "Success", contests.slice(0, maxLength)];
}
async function createWidget(maxLength) {
const wg = new ListWidget();
const [valid, lastUpd, err, contests] = await fetchContestSchedule(maxLength=maxLength);
console.log(err);
// Background color
const gradient = new LinearGradient();
gradient.colors = [
new Color("141414"),
new Color("13233f"),
];
gradient.locations = [
0.0,
1.0,
];
wg.backgroundGradient = gradient;
// Header
wg.addSpacer(4);
const widgetHeader = wg.addStack();
const iconStack = widgetHeader.addStack();
iconStack.addSpacer(6);
const iconElement = iconStack.addText(valid ? "AC" : "WA");
iconStack.addSpacer(6);
iconElement.textColor = new Color("fefffe");
iconElement.font = new Font("GillSans", 16); // Lato Fontがないため...
iconStack.backgroundColor = new Color(valid ? "5cb85c" : "efad4e");
iconStack.cornerRadius = 4;
widgetHeader.addSpacer(8);
const titleElement = widgetHeader.addText("Contest Schedule");
titleElement.font = new Font("Avenir-Medium", 16);
titleElement.textColor = new Color("fefffe");
titleElement.textOpacity = 0.7;
wg.addSpacer(8);
// Upcoming Contests
if (contests.length === 0) {
wg.addSpacer();
if (valid) {
const noContest = wg.addText("No Upcoming Contest :(");
noContest.font = Font.boldRoundedSystemFont(22);
noContest.centerAlignText();
} else {
const errRow = wg.addText(err);
errRow.textSize = 10;
errRow.textColor = Color.red();
}
} else {
let isFirst = true;
for (const contest of contests) {
if (!isFirst) wg.addSpacer(4);
isFirst = false;
const start = new Date(contest.start + '+0000');
const contestHeader = wg.addText(`${date2str(start)} / ${contest.resource.name}`);
contestHeader.font = Font.regularRoundedSystemFont(8);
contestHeader.textColor = new Color("fefffe");
contestHeader.url = contest.href;
const contestBody = wg.addText(`${contest.event}`);
contestBody.font = Font.boldRoundedSystemFont(12);
contestBody.textColor = new Color("fefffe");
contestBody.url = contest.href;
}
}
wg.addSpacer();
// Last Update & Error
const widgetFooter = wg.addStack();
if (!valid && contests.length !== 0) {
const errCell = widgetFooter.addText(err);
errCell.font = new Font("Avenir-Medium", 8);
errCell.textOpacity = 0.8;
errCell.textColor = Color.red();
}
widgetFooter.addSpacer();
const updCell = widgetFooter.addText(`Last Update: ${lastUpd === "" ? "-" : date2str(new Date(lastUpd))}`);
// const updCell = widgetFooter.addText(`Last Update: ${lastUpd === "" ? "-" : date2str(new Date(lastUpd))} [${date2str(new Date())}]`);
updCell.font = new Font("Avenir-Medium", 8);
updCell.textColor = new Color("fefffe");
updCell.textOpacity = 0.7;
return wg;
}
(async function() {
if (config.runsInWidget) {
let maxLength = 3;
if (config.widgetFamily === "large") maxLength = 9;
const wg = await createWidget(maxLength=maxLength);
Script.setWidget(wg);
} else {
// for debugging
const wg = await createWidget(maxLength=9);
wg.presentLarge();
}
Script.complete();
})();
@ryo-n
Copy link

ryo-n commented Jun 4, 2021

  • v2 APIができたようなのでv2 APIに移行しても良いかもしれません。 https://clist.by/api/v2/doc/
  • "resource_id__in" というリクエストパラメーターもあるので、こちらを利用すればresouce_idの個数分ループを回す必要がなくなると思います
  • "start__gt" ではなく "end__gt"で指定するとahcや典型90などの開催中コンテストの情報も取得できると思います。 (これは好みだと思いますが。)

@dchular
Copy link

dchular commented Sep 8, 2021

素晴らしいウィジェットありがとうございます!
自分は、完全に好みですけど、44~45行目に曜日を入れて活用させて頂いています。

const dayOfWeek = date.getDay() ; // 曜日(数値)
const dayOfWeekStr = [ "日", "月", "火", "水", "木", "金", "土" ][dayOfWeek] ; // 曜日(日本語表記)
return ${dateStr}(${dayOfWeekStr})${timeStr};

https://lab.syncer.jp/Web/JavaScript/Snippet/3/
↑かたコピペしたので、コメント付き、好みでスペース入れて下さい)

@dchular
Copy link

dchular commented Sep 8, 2021

(僕みたいに、コンテストID編集して、すぐに更新したい人は、53行目に1 || って入れればいけます)
すぐにアップデートできるようの変数がcontestIdのすぐそこにあるか、lastContestIdを覚えて、編集されていると時間に関係なく更新してくれると便利だなと思いました。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment