はじめに
私は毎日AtCoderを遊んでいるのですが、ふと自分のGitHubを見たときにContributionsグラフが真っ白なのを見て悲しくなりました。
「こんなに毎日コードを書いているのに!」と思い、AtCoderの提出を定期的に取得して自身のリポジトリにプッシュする + そのpushを自分のContributionsグラフに反映させるGitHub Actionsを作成しました。
また、昨日や一昨日に解いた問題のソースコードを改めて見たくなることもあるため、日付ディレクトリで整理し、あとから見返しやすいようにしています。
setup
TypeScript プロジェクトの作成
実行スクリプトはTypeScriptで記述し、ts-nodeで動かします。次の手順でプロジェクトを初期化し、必要なライブラリをインストールしてください。
npm init -y
npm i cheerio dayjs zod
npm i -D typescript @types/node @types/cheerio ts-node
npx tsc --init
package.jsonのscriptsに以下を追加し、ts-nodeでスクリプトを実行できるようにします。実行ファイルはscript/run.tsとします。
{
...
"version": "1.0.0",
"scripts": {
"tsnode": "ts-node script/run.ts"
},
...
}
settings.json作成
AtCoderの取得対象ユーザー名と、前回取得した時刻(UNIX秒)を管理するsettings.jsonをプロジェクトルートに配置します。lastModifiedは初回実行時のみ適当なUNIX秒を設定してください。後続の実行で自動的に更新されます。
{
"userName": "<your-atcoder-user-name>",
"lastModified": <適当なunix秒>
}
こちらでセットアップは完了です。script/run.tsの中身を書いていきます。
run.ts
run.tsの中身は以下です。
お気持ちはコメントに書いておきました。
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { z } from "zod";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { exit } from "process";
import * as cheerio from "cheerio";
import { execSync } from "child_process";
dayjs.extend(utc);
dayjs.extend(timezone);
const SETTING_JSON = "settings.json";
const BASE_API_ENDPOINT =
"https://kenkoooo.com/atcoder/atcoder-api/v3/user/submissions";
const BASE_SUBMISSION_ENDPOINT = "https://atcoder.jp/contests";
type Setting = {
userName: string;
lastModified: number;
};
const submission = z.object({
id: z.number(),
contest_id: z.string(),
problem_id: z.string(),
language: z.string(),
result: z.string(),
});
// ここで現在のyyyymmddを取得して、そのディレクトリに提出物ファイルを作っていきます。
const getCurrent = () => {
const yyyymmdd = dayjs()
.tz("Asia/Tokyo")
.format("YYYYMMDD");
return {
currentUnix: dayjs().unix(),
yyyymmdd,
};
};
// settings.jsonのlastModifiedを更新 & commitしてコードを終了する
const end = (json: Setting, yyyymmdd: string, count: number) => {
writeFileSync(SETTING_JSON, JSON.stringify(json), "utf-8");
execSync(`git add ${SETTING_JSON}`);
const commitMessage = `AtCoder submissions on ${yyyymmdd} - ${count} submissions`;
execSync(`git commit -m "${commitMessage}"`);
exit(0);
};
const main = async () => {
// 現在時刻の取得と設定JSONからAtCoderユーザーとlastModifiedの取得
const { currentUnix, yyyymmdd } = getCurrent();
const settings = JSON.parse(readFileSync(SETTING_JSON, "utf-8")) as Setting;
const user = settings.userName;
const lastModified = settings.lastModified;
const nextJson = { ...settings, lastModified: currentUnix };
if (!user || !lastModified) {
throw new Error("settings.jsonが壊れてます!");
}
// kenkoooさんありがとうございます!
const endpoint = `${BASE_API_ENDPOINT}?user=${user}&from_second=${lastModified}`;
const resp = await fetch(endpoint);
const json = await resp.json();
const submissions = submission.array().parse(json);
// 自分は普段Rustしかつかわないので提出をRustでフィルタしていて、ACした提出のみをcommitするようにしています。
const rustAcSubs = submissions.filter(
({ result, language }) =>
result === "AC" && language.toLowerCase().startsWith("rust"),
);
// 提出がない場合はコミットしないように
if (rustAcSubs.length === 0) {
return;
}
const acCount = rustAcSubs.length;
// submissions/yyyymmdd/ ディレクトリの作成
const dir = `submissions/${yyyymmdd}`;
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
// 同じ問題への違う提出があった際に別々のファイルとして格納できるように提出数をもっておくcounter
const counter: Record<string, number> = {};
for (const { contest_id, id, problem_id } of rustAcSubs) {
const endpoint = `${BASE_SUBMISSION_ENDPOINT}/${contest_id}/submissions/${id}`;
const resp = await fetch(endpoint);
const html = await resp.text();
// 提出コードの取得
const $ = cheerio.load(html);
const code = $("#submission-code").text();
const baseFileName = `submissions/${yyyymmdd}/${problem_id}`;
const preCount = counter[problem_id];
// 同じ問題への提出が重なった場合はsubmissions/20250101/abc123a-2.rs とかで格納されるように
const fileName = preCount
? `${baseFileName}_${preCount + 1}.rs`
: `${baseFileName}.rs`;
const cc = preCount ?? 0;
counter[problem_id] = cc + 1;
// ファイルの作成・git操作
writeFileSync(fileName, code, "utf-8");
execSync(`git add ${fileName}`);
const commitMessage = `Submission for ${problem_id} on ${yyyymmdd}`;
console.log(commitMessage);
execSync(`git commit -m "${commitMessage}"`);
}
end(nextJson, yyyymmdd, acCount);
};
main().catch(console.error);
次にこれを日時実行するようなactionsを定義していきます。
actions.yml
毎日23:55(JST)にスクリプトを実行し、差分をプッシュするWorkflowを定義します。クローン、依存関係のインストール、スクリプト実行、プッシュまでを自動化します。
name: atcoder submission fetcher
on:
schedule:
cron: '55 14 * * *'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.ATCODER_SUBMIT_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version-file: .tool-versions
- name: Configure Git
run: |
git config user.name <your user name>
git config user.email <your email>
- name: Install dependencies
run: npm ci
- name: Run fetch script
run: npm run exec
- name: Push changes
run: git push
PATについて & 登録方法
with:
token: ${{ secrets.ATCODER_SUBMIT_TOKEN }}
ここでPATを引っ張ってきていますがこの理由を記しておきます。
「提出をただgithubにpushしたい!」という要件だけであれば
GitHub Actions が自動で付与してくれるtoken(secrets.GITHUB_TOKEN) を指定すれば十分で、PATの登録は必要ありません。
しかし、今回は自分のContributionsグラフに緑を生やしたい!というわがままがあるので、commit, pushを自分のものとして行いたいです。
その際に自分の認証情報が必要になるので、PATを設定しているというわけです。
PATの生成
GitHubのコンソールから、アカウントの「Settings」 -> 「Developer Settings」-> 「Personal Access Token」に移動して作成できます。
対象のリポジトリへのAccessを許可していきます。
Permissionsに関しては深く理解していないですが、自分は
- Actions
- Contents
に関してRead And Write アクセスを付与しています。(最小権限じゃない可能性大アリです)
作成すると、github_pat
という文字列から始まるトークンが払い出されます。
このトークンをactionsに設定していきます。
(このトークンは違う画面遷移しちゃうと二度と見れなくなるので注意してください)
リポジトリへのPATの登録
対象のリポジトリにいって、Settings -> Actions secrets and variables -> Environment secrets -> Actions secrets -> New secret へ移動して、先ほど取得したPATをリポジトリに登録します。
Nameはactionsで指差している変数名と一致させてください。(↑の例ではATCODER_SUBMIT_TOKEN
です)
これで完了です。
あとは、23時55分を楽しみに待っていると提出コードを自動的にpushしてくれます。
注意事項
- Contributionsグラフに反映させたい!日時で提出を分けたい!ということを満たせればなんでもよかったので、cargoプロジェクトとかを設定していません。そのためこれをcloneしてもそのまま実行できるわけではないことにご注意ください。
- secrets.GITHUB_TOKENを使用する場合は、必要に応じてWorkflowの権限設定を追加してください。
おわりに
これで自分のContributionsグラフも豊かになってくれそうです。
どなたかのご参考になれば幸いです!