diff --git a/.github/workflows/refresh-figures.yml b/.github/workflows/refresh-figures.yml new file mode 100644 index 0000000..73896e0 --- /dev/null +++ b/.github/workflows/refresh-figures.yml @@ -0,0 +1,57 @@ +name: Refresh figures + +# 사이트에 적힌 수집량은 백엔드가 매주 공공데이터를 새로 받아 오면 낡는다. +# 사람이 직접 고치는 절차를 없애기 위해 주기적으로 받아 와 PR 을 연다. +on: + schedule: + - cron: '0 21 * * 1' # 매주 화요일 06:00 KST (백엔드 시설 동기화 이후) + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + refresh: + name: 수치 갱신 PR + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + # 백엔드가 공개되기 전에는 조용히 건너뛴다. 실패로 울리면 알림이 무의미해진다. + - name: 백엔드 주소 확인 + id: guard + run: | + if [ -z "${{ vars.STATS_API_BASE }}" ]; then + echo "STATS_API_BASE 변수가 없어 건너뜁니다." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: 수치 받아 오기 + if: steps.guard.outputs.skip == 'false' + env: + STATS_API_BASE: ${{ vars.STATS_API_BASE }} + # 넷 중 하나라도 못 받으면 스크립트가 파일을 손대지 않고 실패한다. + # 일부만 갱신하고 날짜를 새로 찍으면 낡은 값에 새 날짜가 붙어 거짓말이 된다. + run: node scripts/refresh-figures.mjs + + - name: 변경분 PR 생성 + if: steps.guard.outputs.skip == 'false' + uses: peter-evans/create-pull-request@v7 + with: + branch: chore/refresh-figures + title: 'chore(content): 수집량 갱신' + commit-message: 'chore(content): 수집량과 기준일 갱신' + body: | + 백엔드 공개 통계에서 받아 온 값입니다. 값과 `MEASURED_ON` 이 함께 바뀝니다. + + 사람이 확인할 것은 하나입니다 — **숫자가 갑자기 크게 줄지 않았는지.** + 공공데이터 수집이 부분 실패하면 값이 뚝 떨어질 수 있고, 그대로 올리면 + 사이트가 실제보다 적은 수를 보여 줍니다. + delete-branch: true diff --git a/README.md b/README.md index acf9332..e74ace5 100644 --- a/README.md +++ b/README.md @@ -80,12 +80,26 @@ npm run verify # 로컬에서 같은 검사 (out/ 을 먼저 서빙해야 합 사이트에 적힌 수집량은 `src/content/figures.ts` 한 곳에 있고 `MEASURED_ON` 과 함께 둡니다. 백엔드가 매주 공공데이터를 새로 받아 오므로 값이 낡습니다. -1. `CareCode_Interface` 에서 `./gradlew liveSyncCheck` 로 실측 -2. `figures.ts` 의 값과 `MEASURED_ON` 을 **함께** 수정 -3. `main` 에 반영하면 자동 배포 +**자동**으로 매주 화요일 새벽에 백엔드 공개 통계에서 받아 와 PR 을 엽니다. -값만 고치고 날짜를 안 고치면 거짓말이 됩니다. 매주 화요일 새벽에도 자동으로 다시 빌드하지만, -그건 코드에 적힌 값을 다시 그리는 것일 뿐 실측값을 새로 받아 오지는 않습니다. +| 값 | 출처 | +|---|---| +| 어린이집 · 유치원 | `GET /facilities/statistics` 의 `typeDistribution` | +| 소아청소년과 | `GET /health/hospitals/statistics` | +| 정부지원 서비스 | `GET /policies/statistics` | + +넷 중 **하나라도 못 받으면 아무것도 바꾸지 않습니다.** 일부만 갱신하고 날짜를 새로 찍으면 +낡은 값에 새 날짜가 붙어 거짓말이 됩니다. + +동작하려면 저장소 변수 `STATS_API_BASE` 에 백엔드 공개 주소가 있어야 합니다. 없으면 워크플로가 +조용히 건너뜁니다 — 백엔드가 공개되기 전에 실패로 울리면 알림이 무의미해지기 때문입니다. + +```bash +STATS_API_BASE=https://api.example.com npm run refresh # 로컬에서 직접 +``` + +PR 에서 사람이 볼 것은 하나입니다 — **숫자가 갑자기 크게 줄지 않았는지.** 공공데이터 수집이 +부분 실패하면 값이 뚝 떨어질 수 있습니다. ## 원칙 diff --git a/package.json b/package.json index 7ee0f2c..d734bb3 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "verify": "node scripts/verify-pages.mjs" + "verify": "node scripts/verify-pages.mjs", + "refresh": "node scripts/refresh-figures.mjs" }, "dependencies": { "next": "15.3.5", diff --git a/scripts/refresh-figures.mjs b/scripts/refresh-figures.mjs new file mode 100644 index 0000000..d706bf5 --- /dev/null +++ b/scripts/refresh-figures.mjs @@ -0,0 +1,90 @@ +/** + * 백엔드 공개 통계에서 수집량을 받아 figures.ts 를 갱신한다. + * + * 이 사이트는 "근거 없는 숫자는 쓰지 않는다" 를 내세운다. 그래서 값을 바꿀 때 기준일도 같이 + * 바꿔야 하고, 넷 중 하나라도 못 받으면 **아무것도 바꾸지 않는다.** 일부만 갱신하고 날짜를 + * 새로 찍으면 낡은 값에 새 날짜가 붙어 거짓말이 된다. + * + * 사용법: + * STATS_API_BASE=https://api.example.com node scripts/refresh-figures.mjs + * + * 종료 코드 + * 0 갱신함 (또는 변경 없음) + * 1 실패 — 파일은 손대지 않았다 + * 2 STATS_API_BASE 가 없어 건너뜀 + */ +import { readFile, writeFile } from 'node:fs/promises' + +const BASE = (process.env.STATS_API_BASE ?? '').replace(/\/$/, '') +const FILE = new URL('../src/content/figures.ts', import.meta.url) + +if (!BASE) { + console.log('STATS_API_BASE 가 없어 건너뜁니다. 백엔드가 공개된 뒤 설정하세요.') + process.exit(2) +} + +/** 응답이 이상하면 조용히 0 을 쓰지 않고 실패한다. */ +const getJson = async (path) => { + const res = await fetch(`${BASE}${path}`, { headers: { accept: 'application/json' } }) + if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`) + return res.json() +} + +const positive = (value, label) => { + const n = Number(value) + if (!Number.isFinite(n) || n <= 0) throw new Error(`${label} 값이 이상합니다: ${JSON.stringify(value)}`) + return Math.round(n) +} + +/** 시설 통계의 typeDistribution 키는 FacilityType enum 이름이다. */ +const pickFacility = (distribution, keys, label) => { + for (const key of keys) { + if (distribution?.[key] != null) return positive(distribution[key], label) + } + throw new Error(`${label} 을(를) typeDistribution 에서 찾지 못했습니다: ${Object.keys(distribution ?? {}).join(', ')}`) +} + +let counts +try { + const [facilities, policies, hospitals] = await Promise.all([ + getJson('/facilities/statistics'), + getJson('/policies/statistics'), + getJson('/health/hospitals/statistics'), + ]) + + const dist = facilities.typeDistribution + counts = { + 어린이집: pickFacility(dist, ['DAYCARE', 'CHILDCARE', '어린이집'], '어린이집'), + 유치원: pickFacility(dist, ['KINDERGARTEN', '유치원'], '유치원'), + 소아청소년과: positive(hospitals.byType?.['소아청소년과'] ?? hospitals.total, '소아청소년과'), + '정부지원 서비스': positive(policies.totalPolicies, '정부지원 서비스'), + } +} catch (error) { + console.error(`수치를 받지 못했습니다: ${error.message}`) + console.error('낡은 값에 새 날짜를 붙이지 않도록 파일을 고치지 않았습니다.') + process.exit(1) +} + +const today = new Date().toISOString().slice(0, 10) +let source = await readFile(FILE, 'utf8') +const before = source + +for (const [gets, count] of Object.entries(counts)) { + const pattern = new RegExp(`(gets: '${gets}', count: )[\\d_]+`) + if (!pattern.test(source)) { + console.error(`figures.ts 에서 ${gets} 항목을 찾지 못했습니다. 구조가 바뀐 것 같습니다.`) + process.exit(1) + } + source = source.replace(pattern, `$1${count.toLocaleString('en-US').replace(/,/g, '_')}`) +} + +source = source.replace(/(export const MEASURED_ON = ')[\d-]+(')/, `$1${today}$2`) + +if (source === before) { + console.log('수치와 기준일 모두 그대로입니다.') + process.exit(0) +} + +await writeFile(FILE, source) +console.log(`갱신 (${today})`) +Object.entries(counts).forEach(([k, v]) => console.log(` ${k}: ${v.toLocaleString('ko-KR')}`))