Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
.DS_Store
dist
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

매일 쓰지만 정확히는 모르는 것들을 물어봅니다. 예를 들어 `no-cache`가 실제로 무슨 뜻인지, 304가 왜 공짜가 아닌지, 코덱과 컨테이너가 어떻게 다른지를 묻습니다.

**→ [퀴즈 풀어보기](https://midagedev.github.io/cachehit)**
**→ [퀴즈 풀어보기](https://cachehit.pages.dev)**

---

Expand Down Expand Up @@ -105,6 +105,19 @@ npm i -D playwright && npx playwright install chromium
node tools/e2e.mjs
```

## 호스팅과 익명 집계

사이트는 Cloudflare Pages(https://cachehit.pages.dev)에서 서빙됩니다. 예전 주소
(midagedev.github.io/cachehit)는 이 주소로 리다이렉트됩니다.

같은 오리진의 `/collect` 엔드포인트([functions/collect.js](functions/collect.js))가
익명 집계를 받습니다. **저장되는 것은 카운터 증가뿐입니다** — IP, User-Agent, 쿠키,
식별자는 읽지도 저장하지도 않습니다. 수집 항목은 [스키마](tools/analytics-schema.sql)가
전부입니다: 문항별 보기 선택 분포, 확신도×정답 교차표, 일 단위 방문·완주·복사·공유 횟수.
이 데이터는 "선택률 5% 미만인 오답은 죽은 보기다"([AUTHORING.md](AUTHORING.md) §7)를
실행하기 위한 것이고, 집계 전문은 누구나 [`/stats`](https://cachehit.pages.dev/stats)에서
볼 수 있습니다. 로컬 실행과 E2E에서는 비컨이 나가지 않으며, 그 게이트는 E2E가 검사합니다.

## 라이선스

문항과 해설: [CC BY 4.0](LICENSE-CONTENT) · 코드: [MIT](LICENSE)
35 changes: 33 additions & 2 deletions assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const LENGTHS = [10, 20]
const DEFAULT_LENGTH = 10
const STORE_KEY = 'cachehit.best.v1'
const LEN_KEY = 'cachehit.len.v1'
const SITE_URL = 'https://midagedev.github.io/cachehit'
const SITE_URL = 'https://cachehit.pages.dev'

const TOPIC_LABEL = {
cdn: 'CDN',
Expand All @@ -31,6 +31,29 @@ const GRADES = [
const $ = (s) => document.querySelector(s)
const $$ = (s) => Array.from(document.querySelectorAll(s))

/* ── 익명 집계 ─────────────────────────────────────── */
// 무엇을 어떻게 세는지는 functions/collect.js 와 README 에 공개돼 있고, 저장되는 것은
// 카운터 증가뿐이다(식별자 없음). 수집은 배포 호스트에서만 한다 — 로컬 실행과
// E2E(cachehit.test)는 이 게이트에서 걸러진다.
const ANALYTICS_HOSTS = ['cachehit.pages.dev', 'cachehit.midagedev.com']
function track(t, extra) {
try {
if (!ANALYTICS_HOSTS.includes(location.hostname)) return
const body = JSON.stringify({ t, ...extra })
// sendBeacon: 페이지 이탈 중에도 유실되지 않는 전송 경로. 거부되면 keepalive fetch 로.
if (!navigator.sendBeacon('/collect', new Blob([body], { type: 'application/json' })))
fetch('/collect', { method: 'POST', body, keepalive: true }).catch(() => {})
} catch { /* 집계는 실패해도 퀴즈를 막지 않는다 */ }
}
function trackPageview() {
try {
const day = new Date().toISOString().slice(0, 10)
if (localStorage.getItem('cachehit.pv.v1') === day) return // 브라우저당 하루 한 번
localStorage.setItem('cachehit.pv.v1', day)
} catch { /* 저장이 막힌 브라우저에서는 방문마다 집계된다 — 근사치의 한계로 받아들인다 */ }
track('pageview')
}

const state = {
pool: [], // 전체 문항
length: DEFAULT_LENGTH,
Expand Down Expand Up @@ -89,6 +112,8 @@ async function load() {
}

$('#btn-start').addEventListener('click', () => startRound(pickQuestions(state.length)))

trackPageview()
}

/* 라운드 길이. 20문항은 길다는 실사용 피드백이 있어 10을 기본으로 둔다 —
Expand Down Expand Up @@ -202,6 +227,8 @@ function grade(confidence) {
const isCorrect = picked.correct === true

state.answers.push({ q, picked, correct: isCorrect, confidence })
// opt 는 셔플 전 원본 인덱스로 보낸다 — 집계는 원본 데이터의 보기 순서 기준이다.
track('answer', { qid: q.id, opt: q.options.indexOf(picked), confidence, correct: isCorrect })

$$('#q-options .opt').forEach((b, j) => {
b.disabled = true
Expand Down Expand Up @@ -419,6 +446,7 @@ function renderResult() {
`https://x.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(SITE_URL)}`

state._last = { rate, grade: g, hits, total, byTopic, lucky, solidRate }
track('finish', { len: total })
show('#screen-result')
}

Expand Down Expand Up @@ -514,19 +542,22 @@ $('#btn-copy-q').addEventListener('click', async (e) => {
const b = e.currentTarget
const picked = q._shuffled?.[state.pending]
const ok = await copyText(questionMarkdown(q, picked))
if (ok) track('copy')
b.textContent = ok ? '복사했습니다' : '복사 실패'
b.dataset.done = ok ? '1' : ''
clearTimeout(b._t)
b._t = setTimeout(() => { b.textContent = '문항 복사'; b.dataset.done = '' }, 1800)
})
$('#btn-share').addEventListener('click', saveCard)
$('#btn-share').addEventListener('click', () => { track('share'); saveCard() })
$('#btn-tweet').addEventListener('click', () => track('share'))
$('#btn-restart').addEventListener('click', () => {
state.isRetry = false
show('#screen-intro')
})
$('#btn-retry-wrong').addEventListener('click', () => {
const weak = weakAnswers().map((a) => a.q)
if (!weak.length) return
track('retry')
state.isRetry = true
startRound(shuffle(weak))
})
Expand Down
Binary file modified assets/og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions functions/collect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// 익명 집계 수집. 사이트와 같은 오리진(Pages Functions)이라 CORS 가 없다.
//
// 설계 원칙: 저장하는 것은 카운터 증가뿐이다. IP·User-Agent·쿠키·식별자는 읽지도
// 저장하지도 않는다. 무엇을 세는지는 tools/analytics-schema.sql 이 전부이고,
// 읽기는 GET /stats 로 누구에게나 공개된다. 개인 정보가 없으니 숨길 이유가 없고,
// AUTHORING §7(데이터로 오답을 교체한다)의 근거를 누구나 검증할 수 있다.
//
// 받는 이벤트 (POST /collect, JSON):
// {"t":"pageview"} 하루 한 번 (클라이언트가 제한)
// {"t":"answer","qid":"...","opt":0,"confidence":"sure","correct":true}
// {"t":"finish","len":10}
// {"t":"copy"} {"t":"share"} {"t":"retry"}

const EVENTS = new Set(['pageview', 'finish', 'copy', 'share', 'retry'])
const CONFIDENCES = new Set(['sure', 'unsure', 'guess'])
// 문항 id 형식만 통과시킨다. 자유 문자열을 키로 받으면 카운터 테이블이 스팸의 표면이 된다.
const QID = /^[a-z]+-[a-z0-9-]{1,60}-\d{3}$/

const json = (obj, status = 200) =>
new Response(JSON.stringify(obj), {
status,
headers: { 'content-type': 'application/json; charset=utf-8' },
})

export async function onRequestPost({ request, env }) {
// 브라우저가 크로스 사이트에서 보낸 요청은 버린다. 헤더가 없는 요청(curl 등)은
// 막을 수 없고 막지 않는다 — 이 데이터는 공개 집계라 위조의 이득 자체가 없고,
// 방어는 값 검증과 카운터-만-증가 구조로 한다.
const site = request.headers.get('sec-fetch-site')
if (site && site !== 'same-origin') return json({ ok: false }, 403)

let body
try {
body = await request.json()
} catch {
return json({ ok: false }, 400)
}

const day = new Date().toISOString().slice(0, 10)

if (body.t === 'answer') {
const { qid, opt, confidence, correct } = body
if (!QID.test(String(qid))) return json({ ok: false }, 400)
if (!Number.isInteger(opt) || opt < 0 || opt > 3) return json({ ok: false }, 400)
if (!CONFIDENCES.has(confidence)) return json({ ok: false }, 400)
await env.DB.prepare(
`INSERT INTO answers (qid, opt, confidence, correct, n) VALUES (?, ?, ?, ?, 1)
ON CONFLICT(qid, opt, confidence) DO UPDATE SET n = n + 1`
).bind(qid, opt, confidence, correct ? 1 : 0).run()
return json({ ok: true })
}

if (EVENTS.has(body.t)) {
// finish 는 라운드 길이별로 나눠 센다. 10/20 이 아닌 길이(재도전 라운드)는 finish 로 합산.
const key = body.t === 'finish' && (body.len === 10 || body.len === 20)
? `finish${body.len}:${day}`
: `${body.t}:${day}`
await env.DB.prepare(
`INSERT INTO counters (k, n) VALUES (?, 1)
ON CONFLICT(k) DO UPDATE SET n = n + 1`
).bind(key).run()
return json({ ok: true })
}

return json({ ok: false }, 400)
}
15 changes: 15 additions & 0 deletions functions/stats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 집계 공개 읽기. 개인 정보가 없으므로 전체를 그대로 준다(무엇을 세는지의 증명이기도 하다).
// 5분 캐시: 이 응답 자체가 이 퀴즈가 다루는 Cache-Control 의 실전 예다.
export async function onRequestGet({ env }) {
const [answers, counters] = await Promise.all([
env.DB.prepare('SELECT qid, opt, confidence, correct, n FROM answers ORDER BY qid, opt').all(),
env.DB.prepare('SELECT k, n FROM counters ORDER BY k').all(),
])
return new Response(JSON.stringify({ answers: answers.results, counters: counters.results }), {
headers: {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*',
'cache-control': 'public, max-age=300',
},
})
}
7 changes: 4 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@
<meta property="og:type" content="website">
<meta property="og:site_name" content="cachehit">
<meta property="og:locale" content="ko_KR">
<meta property="og:url" content="https://midagedev.github.io/cachehit/">
<meta property="og:url" content="https://cachehit.pages.dev/">
<link rel="canonical" href="https://cachehit.pages.dev/">
<!-- summary_large_image 카드는 이 이미지가 없으면 렌더되지 않는다. 생성: python3 tools/make-og.py -->
<meta property="og:image" content="https://midagedev.github.io/cachehit/assets/og.png">
<meta property="og:image" content="https://cachehit.pages.dev/assets/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="cachehit — 당신의 캐시 히트율은? CDN·캐싱·이미지/동영상 서빙 파이프라인 퀴즈">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="cachehit — 당신의 캐시 히트율은?">
<meta name="twitter:description" content="CDN·캐싱·이미지/동영상 서빙 퀴즈. 모든 오답은 실제 오개념에서 가져왔습니다.">
<meta name="twitter:image" content="https://midagedev.github.io/cachehit/assets/og.png">
<meta name="twitter:image" content="https://cachehit.pages.dev/assets/og.png">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>💾</text></svg>">
<link rel="stylesheet" href="assets/style.css">
</head>
Expand Down
24 changes: 24 additions & 0 deletions tools/analytics-schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- 집계만 저장한다. 원시 이벤트·IP·UA·식별자는 어떤 형태로도 저장하지 않는다.
-- AUTHORING §7 이 요구하는 두 가지가 이 스키마의 전부다:
-- ① 문항별 보기 선택 분포 (선택률 5% 미만 오답 = 죽은 보기)
-- ② 확신도 × 정답 여부 교차표 (확신했는데 틀린 문항이 가장 가치 있다)
-- 적용: wrangler d1 execute cachehit-analytics --remote --file tools/analytics-schema.sql

-- 보기 선택 카운터. (qid, opt, confidence) 한 칸이 교차표의 한 셀이다.
-- opt 는 셔플 전 원본 데이터 기준 보기 인덱스다.
CREATE TABLE IF NOT EXISTS answers (
qid TEXT NOT NULL,
opt INTEGER NOT NULL,
confidence TEXT NOT NULL, -- sure | unsure | guess
correct INTEGER NOT NULL, -- 0/1 (qid+opt 에서 유도 가능하지만 SQL 편의로 저장)
n INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (qid, opt, confidence)
);

-- 일 단위 카운터. k 는 "이벤트:YYYY-MM-DD" (예: pageview:2026-08-27).
-- pageview 는 방문자 수가 아니라 브라우저·일 단위 근사치다: 클라이언트가 localStorage 로
-- 하루 한 번만 보낸다. 서버는 아무 식별자도 받지 않으므로 그 이상은 셀 수 없고, 세지 않는다.
CREATE TABLE IF NOT EXISTS counters (
k TEXT PRIMARY KEY,
n INTEGER NOT NULL DEFAULT 0
);
10 changes: 9 additions & 1 deletion tools/e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const ANSWER = new Map(
JSON.parse(fs.readFileSync(path.join(ROOT, 'data/questions.json'), 'utf8'))
.map((q) => [q.question, q.options.find((o) => o.correct).text]))

const SITE_MARK = 'midagedev.github.io/cachehit'
const SITE_MARK = 'cachehit.pages.dev'

const browser = await chromium.launch()
// https 로 서빙한다 — clipboard API 는 보안 컨텍스트에서만 존재하고, 문항 복사가
Expand All @@ -54,6 +54,12 @@ const jsErrors = []
page.on('pageerror', (e) => jsErrors.push(String(e)))
page.on('console', (m) => { if (m.type() === 'error') jsErrors.push('console: ' + m.text()) })

// 집계 비컨은 배포 호스트에서만 나가야 한다(assets/app.js 의 ANALYTICS_HOSTS 게이트).
// 테스트 호스트에서 한 건이라도 나가면 게이트가 뚫린 것이다 — 이 검사가 없으면
// 로컬 실행·E2E 가 실데이터를 오염시키는 회귀를 아무도 눈치채지 못한다.
const beacons = []
page.on('request', (r) => { if (r.url().includes('/collect')) beacons.push(r.url()) })

await page.route('**/*', (route) => {
const u = new URL(route.request().url())
const file = path.join(ROOT, u.pathname === '/' ? '/index.html' : u.pathname)
Expand Down Expand Up @@ -192,6 +198,8 @@ console.log(`${ROUNDS}문항 진행 — 정답 ${tally.hits} / 찍어서 맞음
console.log(` ${got.line}`)
console.log(` 재도전: ${got.retry}`)
console.log(` 원문 링크가 보인 문항: ${tally.withLinks}/${ROUNDS}`)
if (beacons.length)
fails.push(`테스트 호스트에서 집계 비컨이 ${beacons.length}건 나갔다 — ANALYTICS_HOSTS 게이트가 뚫렸다: ${beacons[0]}`)
if (jsErrors.length) {
console.log(`\nJS 오류 ${jsErrors.length}건`)
for (const e of jsErrors) console.log(` ✗ ${e}`)
Expand Down
4 changes: 2 additions & 2 deletions tools/make-og.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def question_count():
# 로고 라인
d.text((PAD, y), 'cachehit', font=font(34, 'Bold'), fill=ACCENT)
lw = d.textlength('cachehit', font=font(34, 'Bold'))
d.text((PAD + lw + 18, y + 10), 'github.io', font=font(22, 'Regular'), fill=FG_FAINT)
d.text((PAD + lw + 18, y + 10), 'pages.dev', font=font(22, 'Regular'), fill=FG_FAINT)

# 제목
y += 82
Expand Down Expand Up @@ -99,7 +99,7 @@ def question_count():
n = question_count()
left = f'{n}문항 · 4지선다 · 확신도 입력 · 즉시 해설' if n else '4지선다 · 확신도 입력 · 즉시 해설'
d.text((PAD, BAR_Y + 30), left, font=font(25, 'Medium'), fill=FG_DIM)
right = 'midagedev.github.io/cachehit'
right = 'cachehit.pages.dev'
rw = d.textlength(right, font=font(25, 'SemiBold'))
d.text((W - PAD - rw, BAR_Y + 30), right, font=font(25, 'SemiBold'), fill=OK)

Expand Down
28 changes: 28 additions & 0 deletions tools/pack.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env node
// Cloudflare Pages 배포물(dist/)을 조립한다. 레포 루트에는 저작 도구·문항 소스·규약이
// 함께 있으므로 루트를 그대로 배포하면 안 된다 — 사이트가 실제로 서빙하는 파일만 담는다.
//
// 사용: node tools/pack.mjs → wrangler pages deploy
import { cpSync, mkdirSync, rmSync, existsSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const DIST = join(ROOT, 'dist')

rmSync(DIST, { recursive: true, force: true })
mkdirSync(DIST, { recursive: true })

// 사이트가 요청하는 경로 전부. 여기 없는 파일을 index.html 이나 app.js 가 참조하기
// 시작하면 배포에서만 404 가 난다 — e2e 는 레포 루트를 서빙하므로 잡지 못한다.
const FILES = ['index.html', 'assets/app.js', 'assets/style.css', 'assets/og.png', 'data/questions.json']
for (const f of FILES) {
const src = join(ROOT, f)
if (!existsSync(src)) {
console.error(`없는 파일: ${f} — 배포물이 불완전하다`)
process.exit(1)
}
mkdirSync(dirname(join(DIST, f)), { recursive: true })
cpSync(src, join(DIST, f))
}
console.log(`dist/ 조립 완료 (${FILES.length}개 파일)`)
11 changes: 11 additions & 0 deletions wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Cloudflare Pages 설정. 사이트는 dist/ (tools/pack.mjs 산출물), 수집 엔드포인트는
# functions/ (같은 오리진이라 CORS 가 없다). 배포는 리드가 로컬에서:
# node tools/pack.mjs && wrangler pages deploy
name = "cachehit"
pages_build_output_dir = "dist"
compatibility_date = "2026-08-01"

[[d1_databases]]
binding = "DB"
database_name = "cachehit-analytics"
database_id = "2fe19fc6-9e1a-4099-835f-8a1f8521494d"
Loading