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/.github/scripts/update-atcoder-problems-data.mjs b/.github/scripts/update-atcoder-problems-data.mjs new file mode 100644 index 0000000..c32263a --- /dev/null +++ b/.github/scripts/update-atcoder-problems-data.mjs @@ -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); +}); + diff --git a/worker/index.ts b/worker/index.ts index 2e79778..9db9355 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) { 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