From 72673d48566bbd29573626a0e5c5dd5d945202db Mon Sep 17 00:00:00 2001 From: zerozero-0-0 Date: Sun, 12 Oct 2025 17:51:30 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:header=E3=81=AEgithub=20api=E3=81=AE?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- worker/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worker/index.ts b/worker/index.ts index 2e79778..cf41274 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -47,7 +47,8 @@ const fetchWithDefaultInit = async (url: string, init: RequestInit = {}) => const buildGitHubHeaders = (token?: string): Headers => { const headers = new Headers({ "User-Agent": "zerozero-0-0/portfolio", - Accept: "application/vnd.github.v3+json", + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", }); if (token) { From 39e82fded12662f105eeff4cbbee4690029c4c3d Mon Sep 17 00:00:00 2001 From: zerozero-0-0 Date: Mon, 8 Dec 2025 18:06:23 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20actions=E3=81=A7=E5=AE=9A=E6=9C=9F?= =?UTF-8?q?=E5=AE=9F=E8=A1=8C=E3=81=99=E3=82=8B=E3=82=B9=E3=82=AF=E3=83=AA?= =?UTF-8?q?=E3=83=97=E3=83=88=E3=81=AE=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/update-atcoder-problems-data.mjs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/scripts/update-atcoder-problems-data.mjs diff --git a/.github/scripts/update-atcoder-problems-data.mjs b/.github/scripts/update-atcoder-problems-data.mjs new file mode 100644 index 0000000..f52d70f --- /dev/null +++ b/.github/scripts/update-atcoder-problems-data.mjs @@ -0,0 +1,71 @@ +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) { + throw new Error ('Environment variable ATCODER_USERNAME is not set properly.'); +} + +const yesterday = new Date(); +yesterday.setDate(yesterday.getDate() - 1); +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) || history.length === 0) { + throw new Error('AtCoder submissions history is empty or invalid.'); + } + + let cnt = 0; + + for (const submission of history) { + if (submission.result === "AC") cnt++; + } + + 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); +}); + From 4a28072b7443d2e71d920c98456ef3db970d3cac Mon Sep 17 00:00:00 2001 From: zerozero-0-0 Date: Mon, 8 Dec 2025 18:33:18 +0900 Subject: [PATCH 3/6] improve after gemini review --- .github/scripts/update-atcoder-problems-data.mjs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/scripts/update-atcoder-problems-data.mjs b/.github/scripts/update-atcoder-problems-data.mjs index f52d70f..5531f21 100644 --- a/.github/scripts/update-atcoder-problems-data.mjs +++ b/.github/scripts/update-atcoder-problems-data.mjs @@ -3,12 +3,13 @@ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; const namespaceId = process.env.CLOUDFLARE_KV_NAMESPACE_ID; const apiToken = process.env.CLOUDFLARE_API_TOKEN; -if (!username) { - throw new Error ('Environment variable ATCODER_USERNAME is not set properly.'); +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 @@ -32,15 +33,11 @@ async function main() { } const history = await historyRes.json(); - if (!Array.isArray(history) || history.length === 0) { - throw new Error('AtCoder submissions history is empty or invalid.'); + if (!Array.isArray(history)) { + throw new Error('AtCoder submissions history is invalid.'); } - let cnt = 0; - - for (const submission of history) { - if (submission.result === "AC") cnt++; - } + const cnt = history.filter(submission => submission.result === "AC").length; const payload = { cnt: cnt, From 93a8b5516633e4a8b2401481ea13ac01e280ed29 Mon Sep 17 00:00:00 2001 From: zerozero-0-0 Date: Mon, 8 Dec 2025 18:35:45 +0900 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20index.ts=E3=81=AE=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=83=87=E3=83=B3=E3=83=88=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- worker/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worker/index.ts b/worker/index.ts index cf41274..9db9355 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -47,8 +47,8 @@ const fetchWithDefaultInit = async (url: string, init: RequestInit = {}) => const buildGitHubHeaders = (token?: string): Headers => { const headers = new Headers({ "User-Agent": "zerozero-0-0/portfolio", - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", }); if (token) { From d3bfdbb20110bdcce071a697ce6faccb44fed6b1 Mon Sep 17 00:00:00 2001 From: zerozero-0-0 Date: Mon, 8 Dec 2025 18:37:02 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore:=20=E5=A4=89=E6=95=B0=E5=90=8D?= =?UTF-8?q?=E3=82=92gha=20script=E9=96=93=E3=81=A7=E5=85=B1=E9=80=9A?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/update-atcoder-problems-data.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/update-atcoder-problems-data.mjs b/.github/scripts/update-atcoder-problems-data.mjs index 5531f21..c32263a 100644 --- a/.github/scripts/update-atcoder-problems-data.mjs +++ b/.github/scripts/update-atcoder-problems-data.mjs @@ -14,7 +14,7 @@ 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}`; +const kvkey = `atcoder-problems-data:${username}`; async function main() { const historyRes = await fetch(url, { From 46ee7eb1df298478881409685a5e34e029a38cba Mon Sep 17 00:00:00 2001 From: zerozero-0-0 Date: Thu, 11 Dec 2025 14:48:28 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20kv=20=E5=88=9D=E6=9C=9F=E5=8C=96?= =?UTF-8?q?=E7=94=A8=E3=81=AE=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88?= =?UTF-8?q?=E3=82=92=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 16 +++ .../scripts/init-atcoder-problems-data.mjs | 128 ++++++++++++++++++ worker/services/atcoder-problems.ts | 42 ++++++ wrangler.jsonc | 30 ++-- 4 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/scripts/init-atcoder-problems-data.mjs create mode 100644 worker/services/atcoder-problems.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..3ef761f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,16 @@ +## Portfolio +このプロジェクトは私のポートフォリオです。 +Cloudflare Workers にデプロイされています。 + +## 技術 +- React +- TypeScript +- PandasCSS +- Vite +- Biome +- pnpm +- GitHub Actions +- Cloudflare Workers +- Cloudflare KV + + diff --git a/.github/scripts/init-atcoder-problems-data.mjs b/.github/scripts/init-atcoder-problems-data.mjs new file mode 100644 index 0000000..a2f6ad6 --- /dev/null +++ b/.github/scripts/init-atcoder-problems-data.mjs @@ -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); +}); + diff --git a/worker/services/atcoder-problems.ts b/worker/services/atcoder-problems.ts new file mode 100644 index 0000000..69b953e --- /dev/null +++ b/worker/services/atcoder-problems.ts @@ -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 + +export function createAtCoderLatestRateFetcher() { + return async function fetchLatestRate(env: Env): Promise { + 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(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, + }; + }; +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 96c5d0d..0a6c41c 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -8,23 +8,29 @@ "main": "worker/index.ts", "compatibility_date": "2025-09-13", "assets": { - "directory": "./dist", + "directory": "./dist", "not_found_handling": "single-page-application" }, "observability": { "enabled": true }, - "kv_namespaces": [ - { - "binding": "LANG_STATS", - "id": "2cc3b562db82456fb94a89c17eeb2c80", - "preview_id": "4257faad9fce48289834d9052b5aa44c" - } - ], - "compatibility_flags": [ - "nodejs_compat" - ] - + "kv_namespaces": [ + { + "binding": "LANG_STATS", + "id": "2cc3b562db82456fb94a89c17eeb2c80", + "preview_id": "4257faad9fce48289834d9052b5aa44c" + } + ], + "compatibility_flags": [ + "nodejs_compat" + ], + // "d1_databases": [ + // { + // "binding": "atcoder_grinding_log", + // "database_name": "atcoder-grinding-log", + // "database_id": "d5a93d3b-0095-4ae1-bf67-ee762dedc030" + // } + // ] /** * Smart Placement * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement