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"