-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/install atcoder heatmap #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
72673d4
39e82fd
4a28072
93a8b55
d3bfdbb
46ee7eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| ## Portfolio | ||
| このプロジェクトは私のポートフォリオです。 | ||
| Cloudflare Workers にデプロイされています。 | ||
|
|
||
| ## 技術 | ||
| - React | ||
| - TypeScript | ||
| - PandasCSS | ||
| - Vite | ||
| - Biome | ||
| - pnpm | ||
| - GitHub Actions | ||
| - Cloudflare Workers | ||
| - Cloudflare KV | ||
|
|
||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| const username = process.env.ATCODER_USERNAME; | ||
| const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; | ||
| const namespaceId = process.env.CLOUDFLARE_KV_NAMESPACE_ID; | ||
| const apiToken = process.env.CLOUDFLARE_API_TOKEN; | ||
| const firstSubmissionEpochSec = parseInt( | ||
| process.env.ATCODER_FIRST_SUBMISSION_EPOCH_SEC, | ||
| 10, | ||
| ); | ||
| const kvKey = `atcoder-problems-data:${username}`; // update スクリプトと同じキー構造 | ||
|
|
||
| if (!(username && accountId && namespaceId && apiToken && firstSubmissionEpochSec)) { | ||
| throw new Error("Environment variables are not set properly."); | ||
| } | ||
|
|
||
| const ATCODER_API_BASE = "https://kenkoooo.com/atcoder/atcoder-api/v3"; | ||
| const FETCH_CHUNK = 500; | ||
| const SLEEP_MS = 150; | ||
|
|
||
| const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | ||
|
|
||
| async function fetchAllSubmissions(user, startEpochSec) { | ||
| let cursor = startEpochSec; | ||
| const submissions = []; | ||
|
|
||
| while (true) { | ||
| const url = `${ATCODER_API_BASE}/user/submissions?user=${user}&from_second=${cursor}`; | ||
| const res = await fetch(url, { | ||
| headers: { | ||
| "User-Agent": "zerozero-0-0/portfolio-init-script", | ||
| Accept: "application/json", | ||
| }, | ||
| }); | ||
|
|
||
| if (!res.ok) { | ||
| const body = await res.text(); | ||
| throw new Error( | ||
| `Failed to fetch AtCoder submissions: ${res.status} ${res.statusText}\n${body}`, | ||
| ); | ||
| } | ||
|
|
||
| const chunk = await res.json(); | ||
| if (!Array.isArray(chunk)) { | ||
| throw new Error("AtCoder submissions history is invalid."); | ||
| } | ||
|
|
||
| if (chunk.length === 0) { | ||
| break; | ||
| } | ||
|
|
||
| submissions.push(...chunk); | ||
|
|
||
| // from_second は inclusive なので次は+1 して重複を避ける | ||
| const lastEpoch = chunk[chunk.length - 1].epoch_second; | ||
| cursor = lastEpoch + 1; | ||
|
|
||
| if (chunk.length < FETCH_CHUNK) { | ||
| break; // 500 未満ならもう取得済み | ||
| } | ||
|
|
||
| await sleep(SLEEP_MS); | ||
| } | ||
|
|
||
| return submissions; | ||
| } | ||
|
|
||
| function buildDailyFirstAc(submissions, { offsetHours = 0 } = {}) { | ||
| const solved = new Set(); | ||
| const daily = {}; | ||
|
|
||
| for (const s of submissions) { | ||
| if (s.result !== "AC") continue; | ||
| if (solved.has(s.problem_id)) continue; | ||
|
|
||
| const date = new Date((s.epoch_second + offsetHours * 3600) * 1000); | ||
| const day = date.toISOString().slice(0, 10); | ||
|
|
||
| daily[day] = (daily[day] || 0) + 1; | ||
| solved.add(s.problem_id); | ||
| } | ||
|
|
||
| return daily; | ||
| } | ||
|
|
||
| async function putToKv(key, value) { | ||
| const kvUrl = `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`; | ||
|
|
||
| const res = await fetch(kvUrl, { | ||
| method: "PUT", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${apiToken}`, | ||
| }, | ||
| body: JSON.stringify(value), | ||
| }); | ||
|
|
||
| if (!res.ok) { | ||
| const body = await res.text(); | ||
| throw new Error(`Failed to update Cloudflare KV: ${res.status} ${res.statusText}\n${body}`); | ||
| } | ||
| } | ||
|
|
||
| async function main() { | ||
| console.log(`Fetching submissions for ${username} from ${firstSubmissionEpochSec} ...`); | ||
| const submissions = await fetchAllSubmissions(username, firstSubmissionEpochSec); | ||
|
|
||
| // API は古い順で返る保証がないので昇順ソート | ||
| submissions.sort((a, b) => a.epoch_second - b.epoch_second); | ||
|
|
||
| const dailyFirstAcCounts = buildDailyFirstAc(submissions, { offsetHours: 0 }); | ||
| const lastFetchedEpochSec = submissions.length | ||
| ? submissions[submissions.length - 1].epoch_second | ||
| : firstSubmissionEpochSec; | ||
|
|
||
| const payload = { | ||
| dailyFirstAcCounts, | ||
| lastFetchedEpochSec, | ||
| fetchedAt: Date.now(), | ||
| }; | ||
|
|
||
| await putToKv(kvKey, payload); | ||
| console.log(`Stored ${Object.keys(dailyFirstAcCounts).length} days into KV at key ${kvKey}`); | ||
| } | ||
|
|
||
| main().catch((err) => { | ||
| console.error(err); | ||
| process.exit(1); | ||
| }); | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| const username = process.env.ATCODER_USERNAME; | ||
| const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; | ||
| const namespaceId = process.env.CLOUDFLARE_KV_NAMESPACE_ID; | ||
| const apiToken = process.env.CLOUDFLARE_API_TOKEN; | ||
|
|
||
| if (!(username && accountId && namespaceId && apiToken)) { | ||
| throw new Error('Environment variables are not set properly.'); | ||
| } | ||
|
|
||
| const yesterday = new Date(); | ||
| yesterday.setDate(yesterday.getDate() - 1); | ||
| yesterday.setHours(0, 0, 0, 0); | ||
| const unixSec = Math.floor(yesterday.getTime() / 1000); | ||
|
|
||
| // 昨日0:00からの提出を取得するためのURL | ||
| const url = `https://kenkoooo.com/atcoder/atcoder-api/v3/user/submissions?user=${username}&from_second=${unixSec}`; | ||
| const kvkey = `atcoder-problems-data:${username}`; | ||
|
|
||
| async function main() { | ||
| const historyRes = await fetch(url, { | ||
| headers: { | ||
| "User-Agent": | ||
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", | ||
| Accept: "application/json", | ||
| "Accept-Language": "ja,en-US;q=0.9,en;q=0.8", | ||
| Referer: `https://kenkoooo.com/atcoder/atcoder-api/v3/user/submissions?user=${username}`, | ||
| } | ||
| }); | ||
|
|
||
| if (!historyRes.ok) { | ||
| const body = await historyRes.text(); | ||
| throw new Error(`Failed to fetch AtCoder submissions: ${historyRes.status} ${historyRes.statusText}\n${body}`); | ||
| } | ||
|
|
||
| const history = await historyRes.json(); | ||
| if (!Array.isArray(history)) { | ||
| throw new Error('AtCoder submissions history is invalid.'); | ||
| } | ||
|
|
||
| const cnt = history.filter(submission => submission.result === "AC").length; | ||
|
|
||
| const payload = { | ||
| cnt: cnt, | ||
| fetchedAt: Date.now(), | ||
| }; | ||
|
|
||
| const kvUrl = `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(KVkey)}`; | ||
|
|
||
| const putRes = await fetch(kvUrl, { | ||
| method: 'PUT', | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "Authorization": `Bearer ${apiToken}`, | ||
| }, | ||
| body: JSON.stringify(payload), | ||
| }); | ||
|
|
||
| if (!putRes.ok) { | ||
| const body = await putRes.text(); | ||
| throw new Error(`Failed to update Cloudflare KV: ${putRes.status} ${putRes.statusText}\n${body}`); | ||
| } | ||
| } | ||
|
|
||
| main().catch((err) => { | ||
| console.error(err); | ||
| process.exit(1); | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| type dataType = { | ||
| ok: boolean; | ||
| rating: number | null; | ||
| }; | ||
|
|
||
| type KvRatingPayload = { | ||
| rating: number; | ||
| fetchedAt: number; | ||
| }; | ||
|
|
||
| const CACHE_KEY_PREFIX = "atcoder-problems-submission"; | ||
| const STALE_THRESHOLD_MS = 1000 * 60 * 60 * 24 * 14; // 14 days | ||
|
Comment on lines
+11
to
+12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new AtCoder Problems cache reader builds its cache key with the Useful? React with 👍 / 👎. |
||
|
|
||
| export function createAtCoderLatestRateFetcher() { | ||
| return async function fetchLatestRate(env: Env): Promise<dataType> { | ||
| const username = env.ATCODER_USERNAME; | ||
| if (!username) { | ||
| throw new Error("AtCoder username is not set in environment variables."); | ||
| } | ||
|
|
||
| const cacheKey = `${CACHE_KEY_PREFIX}:${username}`; | ||
| const cached = await env.LANG_STATS.get<KvRatingPayload>(cacheKey, { | ||
| type: "json", | ||
| }); | ||
| const now = Date.now(); | ||
|
|
||
| if (cached && typeof cached.rating === "number") { | ||
| const isFresh = now - cached.fetchedAt < STALE_THRESHOLD_MS; | ||
| if (isFresh) { | ||
| return { | ||
| ok: true, | ||
| rating: cached.rating, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| ok: false, | ||
| rating: null, | ||
| }; | ||
| }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.