diff --git a/.gitignore b/.gitignore
index fd4f2b0..da592f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
node_modules
.DS_Store
+dist
diff --git a/README.md b/README.md
index 37e5364..da8f26b 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
매일 쓰지만 정확히는 모르는 것들을 물어봅니다. 예를 들어 `no-cache`가 실제로 무슨 뜻인지, 304가 왜 공짜가 아닌지, 코덱과 컨테이너가 어떻게 다른지를 묻습니다.
-**→ [퀴즈 풀어보기](https://midagedev.github.io/cachehit)**
+**→ [퀴즈 풀어보기](https://cachehit.pages.dev)**
---
@@ -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)
diff --git a/assets/app.js b/assets/app.js
index 4ece2ef..a93636e 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -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',
@@ -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,
@@ -89,6 +112,8 @@ async function load() {
}
$('#btn-start').addEventListener('click', () => startRound(pickQuestions(state.length)))
+
+ trackPageview()
}
/* 라운드 길이. 20문항은 길다는 실사용 피드백이 있어 10을 기본으로 둔다 —
@@ -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
@@ -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')
}
@@ -514,12 +542,14 @@ $('#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')
@@ -527,6 +557,7 @@ $('#btn-restart').addEventListener('click', () => {
$('#btn-retry-wrong').addEventListener('click', () => {
const weak = weakAnswers().map((a) => a.q)
if (!weak.length) return
+ track('retry')
state.isRetry = true
startRound(shuffle(weak))
})
diff --git a/assets/og.png b/assets/og.png
index 29cc8dc..e1e8ebe 100644
Binary files a/assets/og.png and b/assets/og.png differ
diff --git a/functions/collect.js b/functions/collect.js
new file mode 100644
index 0000000..1d1d972
--- /dev/null
+++ b/functions/collect.js
@@ -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)
+}
diff --git a/functions/stats.js b/functions/stats.js
new file mode 100644
index 0000000..9bbb509
--- /dev/null
+++ b/functions/stats.js
@@ -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',
+ },
+ })
+}
diff --git a/index.html b/index.html
index 09be65d..f369f73 100644
--- a/index.html
+++ b/index.html
@@ -10,16 +10,17 @@
-
+
+
-
+
-
+
diff --git a/tools/analytics-schema.sql b/tools/analytics-schema.sql
new file mode 100644
index 0000000..0bffdfa
--- /dev/null
+++ b/tools/analytics-schema.sql
@@ -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
+);
diff --git a/tools/e2e.mjs b/tools/e2e.mjs
index 8c2dcfe..1d51931 100644
--- a/tools/e2e.mjs
+++ b/tools/e2e.mjs
@@ -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 는 보안 컨텍스트에서만 존재하고, 문항 복사가
@@ -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)
@@ -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}`)
diff --git a/tools/make-og.py b/tools/make-og.py
index 7d040cf..86576f7 100644
--- a/tools/make-og.py
+++ b/tools/make-og.py
@@ -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
@@ -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)
diff --git a/tools/pack.mjs b/tools/pack.mjs
new file mode 100644
index 0000000..ae369f3
--- /dev/null
+++ b/tools/pack.mjs
@@ -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}개 파일)`)
diff --git a/wrangler.toml b/wrangler.toml
new file mode 100644
index 0000000..9664b3c
--- /dev/null
+++ b/wrangler.toml
@@ -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"