Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/copilot-instructions.md
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


128 changes: 128 additions & 0 deletions .github/scripts/init-atcoder-problems-data.mjs
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);
});

68 changes: 68 additions & 0 deletions .github/scripts/update-atcoder-problems-data.mjs
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);
});

3 changes: 2 additions & 1 deletion worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
42 changes: 42 additions & 0 deletions worker/services/atcoder-problems.ts
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge AtCoder problems fetcher looks under wrong KV key

The new AtCoder Problems cache reader builds its cache key with the atcoder-problems-submission prefix (CACHE_KEY_PREFIX), but both the init/update scripts write the heatmap payload to atcoder-problems-data:${username} (.github/scripts/init-atcoder-problems-data.mjs lines 1-9). Because the prefixes differ, the fetcher will never find the populated KV value and will always return {ok: false, rating: null}, so the new heatmap data cannot be served even after initialization.

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,
};
};
}
30 changes: 18 additions & 12 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading