Compare commits
18 Commits
122b367bed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76f604a094 | ||
|
|
0076eefda2 | ||
|
|
bd045b43c2 | ||
|
|
ac75dcce7e | ||
|
|
0ff648dd7e | ||
|
|
9ef7a01d9a | ||
|
|
66c5dbdb90 | ||
|
|
f113210631 | ||
|
|
6d522b4759 | ||
|
|
7eab1ba57b | ||
|
|
044c725656 | ||
|
|
a5d4895bb9 | ||
|
|
cd59ebbd03 | ||
|
|
3906de1803 | ||
|
|
5b67d6f192 | ||
|
|
edc3a58503 | ||
| ce040e1ce9 | |||
|
|
1a67844e41 |
@@ -4,3 +4,5 @@ __pycache__/
|
||||
venv/
|
||||
.env
|
||||
.git/
|
||||
|
||||
*.md
|
||||
138
.gitea/workflows/cicd.yml
Normal file
138
.gitea/workflows/cicd.yml
Normal file
@@ -0,0 +1,138 @@
|
||||
name: baekjoon-bot-cicd
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
|
||||
jobs:
|
||||
build_push_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup SSH for Gitea
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.NKEY_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
ssh-keyscan -p 2222 -t rsa,ed25519 nkeystudy.site >> ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
cat >> ~/.ssh/config <<'EOF'
|
||||
Host nkey-gitea
|
||||
HostName nkeystudy.site
|
||||
User git
|
||||
Port 2222
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
IdentitiesOnly yes
|
||||
EOF
|
||||
chmod 600 ~/.ssh/config
|
||||
|
||||
- name: Manual checkout via SSH
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git init .
|
||||
git remote add origin "nkey-gitea:${REPO}.git"
|
||||
git fetch --no-tags --prune --depth=1 origin "${SHA}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Ensure docker compose available
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker version
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y docker-compose-plugin
|
||||
fi
|
||||
docker compose version
|
||||
|
||||
- name: Docker login
|
||||
env:
|
||||
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USER}" --password-stdin
|
||||
|
||||
- name: Extract image version from commit message
|
||||
id: version
|
||||
env:
|
||||
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION_TAG=""
|
||||
if printf '%s' "${COMMIT_MESSAGE}" | grep -Eq '\[[0-9]+\.[0-9]+\.[0-9]+\]'; then
|
||||
VERSION_TAG="$(printf '%s' "${COMMIT_MESSAGE}" | sed -nE 's/.*\[([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p' | head -n1)"
|
||||
fi
|
||||
|
||||
echo "version_tag=${VERSION_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push image
|
||||
env:
|
||||
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
IMAGE_NAME: baekjoon-bot
|
||||
VERSION_TAG: ${{ steps.version.outputs.version_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${DOCKERHUB_USER}/${IMAGE_NAME}:latest"
|
||||
if [ -n "${VERSION_TAG}" ]; then
|
||||
VERSIONED_IMAGE="${DOCKERHUB_USER}/${IMAGE_NAME}:${VERSION_TAG}"
|
||||
docker build -t "${IMAGE}" -t "${VERSIONED_IMAGE}" .
|
||||
docker push "${VERSIONED_IMAGE}"
|
||||
else
|
||||
docker build -t "${IMAGE}" .
|
||||
fi
|
||||
|
||||
docker push "${IMAGE}"
|
||||
|
||||
- name: Deploy on server (compose pull/up)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose -p nkeys-apps -f /nkeysworld/compose.apps.yml pull baekjoon-bot
|
||||
docker compose -p nkeys-apps -f /nkeysworld/compose.apps.yml up -d baekjoon-bot
|
||||
docker image prune -f
|
||||
|
||||
- name: Discord Notification
|
||||
if: always()
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
STATUS="SUCCESS"
|
||||
COLOR=3066993
|
||||
DESC="Baekjoon bot build/push/deploy succeeded."
|
||||
else
|
||||
STATUS="FAILURE"
|
||||
COLOR=15158332
|
||||
DESC="Baekjoon bot build or deploy failed."
|
||||
fi
|
||||
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"embeds": [{
|
||||
"title": "Baekjoon Bot CI/CD - '"$STATUS"'",
|
||||
"description": "'"$DESC"'",
|
||||
"fields": [
|
||||
{ "name": "Repo", "value": "${{ github.repository }}", "inline": true },
|
||||
{ "name": "Commit", "value": "`${{ github.sha }}`", "inline": true },
|
||||
{ "name": "Actor", "value": "${{ github.actor }}", "inline": true },
|
||||
{ "name": "Image Version", "value": "`${{ steps.version.outputs.version_tag || 'latest only' }}`", "inline": true }
|
||||
],
|
||||
"color": '"$COLOR"',
|
||||
"timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"
|
||||
}]
|
||||
}' "${DISCORD_WEBHOOK}"
|
||||
159
README.md
Normal file
159
README.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# baekjoon-bot
|
||||
|
||||
매일 백준(BOJ) 문제를 추천하고 Discord embed payload로 반환하는 FastAPI 서비스.
|
||||
solved.ac 검색 기반 추천과 PostgreSQL 문제집(workbook) 기반 추천 두 가지 모드를 지원하며, n8n 등 외부 스케줄러와 연동하여 Discord 자동 알림을 구성할 수 있다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- **Search 모드**: solved.ac API로 난이도/태그/언어 조건에 맞는 문제를 랜덤 추천
|
||||
- **Workbook 모드**: DB에 저장된 문제집에서 아직 보내지 않은 문제를 순서대로 또는 랜덤으로 추천
|
||||
- **Discord embed**: 모든 응답에 Discord webhook용 embed payload 포함
|
||||
- **Admin API**: 문제집 메타데이터 보강(enrich), 진행상황 초기화(reset)
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- Python 3.12+ / FastAPI / Uvicorn
|
||||
- SQLAlchemy 2.0 (async) + asyncpg (PostgreSQL)
|
||||
- httpx (비동기 HTTP) / requests (동기 HTTP)
|
||||
- Docker / Gitea Actions CI/CD
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
```bash
|
||||
git clone https://nkeystudy.site/gitea/nkey/baekjoon-bot.git
|
||||
cd baekjoon-bot
|
||||
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# .env 파일 생성
|
||||
cat > .env <<'EOF'
|
||||
DATABASE_URL=postgresql+asyncpg://USER:PASSWORD@HOST:5432/DBNAME
|
||||
ADMIN_PASSWORD=change-me
|
||||
EOF
|
||||
|
||||
uvicorn app:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Docker로 실행:
|
||||
```bash
|
||||
docker build -f dockerfile -t baekjoon-bot:local .
|
||||
docker run --rm -p 8000:8000 --env-file .env baekjoon-bot:local
|
||||
```
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
| Method | Endpoint | 설명 | 인증 |
|
||||
|--------|----------|------|------|
|
||||
| GET | `/` | 헬스체크 | - |
|
||||
| GET | `/today` | 오늘의 추천 문제 | - |
|
||||
| POST | `/admin/workbooks/{id}/enrich` | 문제집 메타데이터 보강 | O |
|
||||
| DELETE | `/admin/workbooks/{id}/reset` | 문제집 발송 기록 초기화 | O |
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```bash
|
||||
# 기본 추천 (search 모드)
|
||||
curl -s "http://localhost:8000/today"
|
||||
|
||||
# 난이도/태그/언어 지정
|
||||
curl -s "http://localhost:8000/today?difficulty=6..10&tags=dp,graphs&lang=ko"
|
||||
|
||||
# 문제집 모드
|
||||
curl -s "http://localhost:8000/today?source_mode=workbook&workbook_id=12345"
|
||||
|
||||
# 문제집 메타 보강
|
||||
curl -s -X POST -H "X-Admin-Password: change-me" \
|
||||
"http://localhost:8000/admin/workbooks/12345/enrich"
|
||||
|
||||
# 문제집 진행상황 초기화
|
||||
curl -s -X DELETE -H "X-Admin-Password: change-me" \
|
||||
"http://localhost:8000/admin/workbooks/12345/reset"
|
||||
```
|
||||
|
||||
## Workbook 모드 셋업
|
||||
|
||||
Workbook 모드를 사용하려면 DB에 문제집 데이터를 직접 넣어야 한다.
|
||||
(BOJ 자동 크롤링은 스크래핑 차단으로 현재 미지원)
|
||||
|
||||
### 1. 문제집 등록
|
||||
|
||||
```sql
|
||||
-- id는 BOJ 문제집 URL의 번호 (acmicpc.net/workbook/view/12345)
|
||||
INSERT INTO workbooks (id, title)
|
||||
VALUES (12345, '내 문제집 이름');
|
||||
```
|
||||
|
||||
### 2. 문제 목록 등록
|
||||
|
||||
```sql
|
||||
-- 문제 번호만 넣으면 됨 (제목/난이도/태그는 3단계에서 자동으로 채워짐)
|
||||
INSERT INTO workbook_problems (workbook_id, problem_id) VALUES
|
||||
(12345, 1000),
|
||||
(12345, 1001),
|
||||
(12345, 1002);
|
||||
```
|
||||
|
||||
### 3. 메타데이터 자동 보강
|
||||
|
||||
```bash
|
||||
# solved.ac API로 제목, 난이도, 태그를 자동으로 가져와서 DB에 채움
|
||||
curl -s -X POST \
|
||||
-H "X-Admin-Password: change-me" \
|
||||
"http://localhost:8000/admin/workbooks/12345/enrich"
|
||||
```
|
||||
|
||||
이후 `/today?source_mode=workbook&workbook_id=12345`로 문제를 추천받을 수 있다.
|
||||
모든 문제를 다 뽑은 뒤 다시 처음부터 시작하려면 reset API를 호출하면 된다.
|
||||
|
||||
## 환경변수
|
||||
|
||||
| 변수 | 설명 | 기본값 | 필수 |
|
||||
|------|------|--------|:----:|
|
||||
| `DATABASE_URL` | PostgreSQL 비동기 연결 URL | - | O |
|
||||
| `ADMIN_PASSWORD` | Admin API 비밀번호 | `""` | Admin API 사용 시 |
|
||||
| `SOURCE_MODE_DEFAULT` | 기본 소스 모드 (`search`/`workbook`) | `search` | |
|
||||
| `WORKBOOK_ID_DEFAULT` | workbook 모드 기본 ID | - | |
|
||||
| `DIFFICULTY_MODE_DEFAULT` | 기본 난이도 모드 (`easy`/`hard`/`all`) | `easy` | |
|
||||
| `TAG_MODE_DEFAULT` | 기본 태그 모드 | `easy` | |
|
||||
| `LANG_DEFAULT` | 기본 언어 필터 | `all` | |
|
||||
| `DIFFICULTY_EASY` | easy 난이도 범위 | `6..10` | |
|
||||
| `DIFFICULTY_HARD` | hard 난이도 범위 | `11..15` | |
|
||||
| `DIFFICULTY_ALL` | all 난이도 범위 | `1..30` | |
|
||||
| `TAGS_EASY` / `TAGS_HARD` / `TAGS_ALL` | 태그 프리셋 (CSV) | `""` | |
|
||||
| `TAG_PICK` / `TAG_PICK_EASY` 등 | 태그 선택 정책 (`random`/`none`/전체) | `random` | |
|
||||
| `TAGS_JOIN` | 태그 결합 방식 (`or`/`and`) | `or` | |
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
app.py # FastAPI 엔트리포인트, 라우팅
|
||||
utils.py # 환경변수 헬퍼, solved.ac 검색 쿼리 빌드, HTTP 호출
|
||||
db.py # SQLAlchemy async 엔진/세션
|
||||
workbook_picker.py # 문제집에서 미발송 문제 1개 선택 + 발송 기록
|
||||
workbook_enricher.py # solved.ac로 문제 메타데이터 채우기
|
||||
workbook_importer.py # BOJ 문제집 크롤링 (현재 미사용)
|
||||
dockerfile # Docker 이미지 정의
|
||||
requirements.txt # Python 의존성
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
- **Workflow**: `.gitea/workflows/cicd.yml`
|
||||
- **Trigger**: `main` 브랜치 push (단, 문서 파일만 변경된 경우 스킵)
|
||||
- **Flow**: 수동 checkout → Docker Hub 빌드/푸시 → 서버 배포(`compose.apps.yml`) → Discord 알림
|
||||
|
||||
### 필요한 Secrets
|
||||
|
||||
| Secret | 용도 |
|
||||
|--------|------|
|
||||
| `NKEY_PAT` | Gitea repo fetch 인증 |
|
||||
| `DOCKERHUB_USERNAME` | Docker Hub 네임스페이스 |
|
||||
| `DOCKERHUB_TOKEN` | Docker Hub 로그인 |
|
||||
| `DISCORD_WEBHOOK` | CI/CD 결과 알림 |
|
||||
|
||||
## 상세 문서
|
||||
|
||||
- [API 레퍼런스](docs/API.md)
|
||||
- [개발 가이드](docs/DEVELOPMENT.md)
|
||||
- [운영 가이드](docs/OPERATIONS.md)
|
||||
14
app.py
14
app.py
@@ -6,11 +6,11 @@ from dotenv import load_dotenv
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from utils import env, resolve_difficulty, resolve_tags, build_query, get_problem
|
||||
from utils import env, resolve_difficulty, resolve_tags, build_query, get_problem, require_admin
|
||||
from db import get_db
|
||||
from workbook_picker import pick_from_workbook
|
||||
from workbook_importer import import_workbook
|
||||
from workbook_enricher import enrich_workbook
|
||||
# from workbook_importer import import_workbook
|
||||
|
||||
|
||||
load_dotenv()
|
||||
@@ -30,6 +30,7 @@ async def admin_enrich_workbook(
|
||||
commit_every: int = Query(50, ge=1, le=500, description="몇 개마다 commit 할지"),
|
||||
sleep_sec: float = Query(0.12, ge=0.0, le=2.0, description="solved.ac 호출 사이 sleep"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = Depends(require_admin),
|
||||
):
|
||||
result = await enrich_workbook(
|
||||
db,
|
||||
@@ -45,6 +46,7 @@ async def admin_enrich_workbook(
|
||||
async def reset_workbook_progress(
|
||||
workbook_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = Depends(require_admin),
|
||||
):
|
||||
try:
|
||||
res = await db.execute(
|
||||
@@ -84,7 +86,7 @@ async def today(
|
||||
if not wid:
|
||||
return JSONResponse(status_code=400, content={"error": "workbook_id is required for workbook mode"})
|
||||
|
||||
pid, title, level = await pick_from_workbook(db, wid, pick=workbook_pick)
|
||||
pid, title, level, current_idx, total_cnt = await pick_from_workbook(db, wid, pick=workbook_pick)
|
||||
if not pid:
|
||||
return JSONResponse(status_code=409, content={"error": "no_more_problems_in_workbook", "workbook_id": wid})
|
||||
|
||||
@@ -92,9 +94,10 @@ async def today(
|
||||
solved_url = f"https://solved.ac/problems/id/{pid}"
|
||||
level_text = f"Lv. {level}" if level is not None else "Lv. ?"
|
||||
|
||||
progress_text = f"({current_idx}/{total_cnt})" if current_idx and total_cnt else ""
|
||||
discord_payload = {
|
||||
"embeds": [{
|
||||
"title": "🔔 오늘의 백준 추천 문제 (문제집)",
|
||||
"title": f"🔔 오늘의 백준 추천 문제 (문제집) {progress_text}",
|
||||
"description": (
|
||||
f"**{pid}번: {title}**\n"
|
||||
f"난이도: **{level_text}**\n"
|
||||
@@ -104,7 +107,6 @@ async def today(
|
||||
{"name": "문제 링크", "value": f"[바로가기]({problem_url})", "inline": True},
|
||||
{"name": "해설/정보", "value": f"[Solved.ac]({solved_url})", "inline": True},
|
||||
],
|
||||
"footer": {"text": "매일 오전 10시 정기 알림 (n8n)"}
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -153,7 +155,6 @@ async def today(
|
||||
{"name": "문제 링크", "value": f"[바로가기]({problem_url})", "inline": True},
|
||||
{"name": "해설/정보", "value": f"[Solved.ac]({solved_url})", "inline": True},
|
||||
],
|
||||
"footer": {"text": "매일 오전 10시 정기 알림 (n8n)"}
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -174,6 +175,7 @@ async def today(
|
||||
}
|
||||
|
||||
|
||||
# 백준 사이트는 beautifulsoap 크롤링이 안됨
|
||||
# @app.post("/admin/workbooks/{workbook_id}/import")
|
||||
# async def admin_import_workbook(
|
||||
# workbook_id: int,
|
||||
|
||||
157
docs/API.md
Normal file
157
docs/API.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# API Reference
|
||||
|
||||
## Base URL
|
||||
|
||||
`http://localhost:8000`
|
||||
|
||||
## 인증
|
||||
|
||||
Admin API(`/admin/*`)는 `X-Admin-Password` 헤더가 필요하다.
|
||||
서버의 `ADMIN_PASSWORD` 환경변수와 일치해야 하며, 미설정 시 500 에러를 반환한다.
|
||||
|
||||
---
|
||||
|
||||
## GET /
|
||||
|
||||
헬스체크.
|
||||
|
||||
**응답**: `{"status": "ok"}`
|
||||
|
||||
---
|
||||
|
||||
## GET /today
|
||||
|
||||
오늘의 추천 문제를 반환한다. Search 모드와 Workbook 모드를 지원한다.
|
||||
|
||||
### 공통 파라미터
|
||||
|
||||
| 파라미터 | 타입 | 설명 | 기본값 |
|
||||
|----------|------|------|--------|
|
||||
| `source_mode` | string | `search` 또는 `workbook` | `SOURCE_MODE_DEFAULT` or `search` |
|
||||
|
||||
### Search 모드 파라미터
|
||||
|
||||
| 파라미터 | 타입 | 설명 | 기본값 |
|
||||
|----------|------|------|--------|
|
||||
| `difficulty_mode` | string | `easy`/`hard`/`all` | `DIFFICULTY_MODE_DEFAULT` or `easy` |
|
||||
| `tag_mode` | string | `easy`/`hard`/`all` | `TAG_MODE_DEFAULT` or `easy` |
|
||||
| `difficulty` | string | 난이도 범위 (예: `6..10`). 지정 시 `difficulty_mode`보다 우선 | - |
|
||||
| `tags` | string | 태그 (예: `dp,graphs`). 지정 시 `tag_mode`보다 우선 | - |
|
||||
| `lang` | string | 언어 필터: `ko`/`en`/`ko,en`/`all` | `LANG_DEFAULT` or `all` |
|
||||
|
||||
### Workbook 모드 파라미터
|
||||
|
||||
| 파라미터 | 타입 | 설명 | 기본값 |
|
||||
|----------|------|------|--------|
|
||||
| `workbook_id` | int | 문제집 ID | `WORKBOOK_ID_DEFAULT` |
|
||||
| `workbook_pick` | string | `random` 또는 `level_asc` | `level_asc` |
|
||||
|
||||
### 응답 (Search 모드)
|
||||
|
||||
```json
|
||||
{
|
||||
"source_mode": "search",
|
||||
"difficulty_mode": "easy",
|
||||
"tag_mode": "easy",
|
||||
"lang": "all",
|
||||
"difficulty": "6..10",
|
||||
"tags": ["dp"],
|
||||
"query": "*6..10 (tag:dp)",
|
||||
"problemId": 1234,
|
||||
"title": "문제 제목",
|
||||
"level": 8,
|
||||
"problemUrl": "https://www.acmicpc.net/problem/1234",
|
||||
"solvedUrl": "https://solved.ac/problems/id/1234",
|
||||
"discordPayload": { "embeds": [...] }
|
||||
}
|
||||
```
|
||||
|
||||
### 응답 (Workbook 모드)
|
||||
|
||||
```json
|
||||
{
|
||||
"source_mode": "workbook",
|
||||
"workbook_id": 12345,
|
||||
"problemId": 1234,
|
||||
"title": "문제 제목",
|
||||
"level": 8,
|
||||
"problemUrl": "https://www.acmicpc.net/problem/1234",
|
||||
"solvedUrl": "https://solved.ac/problems/id/1234",
|
||||
"discordPayload": { "embeds": [...] }
|
||||
}
|
||||
```
|
||||
|
||||
### 에러
|
||||
|
||||
| 상태 코드 | 조건 |
|
||||
|-----------|------|
|
||||
| 400 | `difficulty_mode`/`tag_mode` 값이 유효하지 않음 |
|
||||
| 400 | Workbook 모드에서 `workbook_id`를 확정할 수 없음 |
|
||||
| 409 | Workbook에서 더 이상 뽑을 문제가 없음 (`no_more_problems_in_workbook`) |
|
||||
| 503 | Search 모드에서 solved.ac 조회 실패 (`failed_to_fetch_problem`) |
|
||||
|
||||
---
|
||||
|
||||
## POST /admin/workbooks/{workbook_id}/enrich
|
||||
|
||||
문제집에 포함된 문제들의 메타데이터(제목, 난이도, 태그)를 solved.ac API로 채운다.
|
||||
|
||||
### 파라미터
|
||||
|
||||
| 파라미터 | 타입 | 설명 | 기본값 |
|
||||
|----------|------|------|--------|
|
||||
| `only_missing` | bool | `true`면 NULL 필드만 채움, `false`면 전체 덮어씀 | `true` |
|
||||
| `commit_every` | int (1~500) | DB 커밋 배치 크기 | `50` |
|
||||
| `sleep_sec` | float (0.0~2.0) | solved.ac 호출 간 대기 시간 | `0.12` |
|
||||
|
||||
### 요청 예시
|
||||
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-H "X-Admin-Password: change-me" \
|
||||
"http://localhost:8000/admin/workbooks/12345/enrich?only_missing=true"
|
||||
```
|
||||
|
||||
### 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"workbook_id": 12345,
|
||||
"target_count": 100,
|
||||
"updated": 95,
|
||||
"skipped": 3,
|
||||
"failed": 2,
|
||||
"only_missing": true,
|
||||
"commit_every": 50,
|
||||
"sleep_sec": 0.12,
|
||||
"message": "enrich done"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /admin/workbooks/{workbook_id}/reset
|
||||
|
||||
문제집의 발송 기록(`workbook_sends`)을 초기화하여 모든 문제를 다시 추천받을 수 있게 한다.
|
||||
|
||||
### 요청 예시
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
-H "X-Admin-Password: change-me" \
|
||||
"http://localhost:8000/admin/workbooks/12345/reset"
|
||||
```
|
||||
|
||||
### 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"workbook_id": 12345,
|
||||
"deleted_sends": 42,
|
||||
"message": "workbook progress reset (problems can be picked again)"
|
||||
}
|
||||
```
|
||||
99
docs/DEVELOPMENT.md
Normal file
99
docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Development Guide
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `app.py` | FastAPI 엔트리포인트. `/`, `/today`, `/admin/*` 라우팅 |
|
||||
| `utils.py` | 환경변수 헬퍼, solved.ac 검색 쿼리 빌드, HTTP 호출(retry), admin 인증 |
|
||||
| `db.py` | SQLAlchemy async 엔진/세션 설정, `get_db()` DI |
|
||||
| `workbook_picker.py` | 문제집에서 미발송 문제 1개 선택 + `workbook_sends` 기록 |
|
||||
| `workbook_enricher.py` | solved.ac `problem/show`로 문제 메타데이터 채우기 |
|
||||
| `workbook_importer.py` | BOJ 문제집 페이지 크롤링 (현재 미사용, BOJ 스크래핑 차단) |
|
||||
|
||||
## 로컬 셋업
|
||||
|
||||
```bash
|
||||
git clone https://nkeystudy.site/gitea/nkey/baekjoon-bot.git
|
||||
cd baekjoon-bot
|
||||
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
cat > .env <<'EOF'
|
||||
DATABASE_URL=postgresql+asyncpg://USER:PASSWORD@HOST:5432/DBNAME
|
||||
ADMIN_PASSWORD=change-me
|
||||
EOF
|
||||
|
||||
uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## 의존성
|
||||
|
||||
`requirements.txt` 기준:
|
||||
|
||||
- `fastapi` / `uvicorn` - 웹 프레임워크 / ASGI 서버
|
||||
- `sqlalchemy` / `asyncpg` - 비동기 PostgreSQL ORM
|
||||
- `requests` / `httpx` - 동기/비동기 HTTP 클라이언트
|
||||
- `python-dotenv` - `.env` 파일 로딩
|
||||
- `beautifulsoup4` / `lxml` - HTML 파싱 (workbook_importer용, 현재 미사용)
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
PostgreSQL 필수. DDL은 레포에 포함되어 있지 않으므로 수동으로 생성해야 한다.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS workbooks (
|
||||
id BIGINT PRIMARY KEY,
|
||||
title TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'boj',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbook_problems (
|
||||
workbook_id BIGINT NOT NULL REFERENCES workbooks(id) ON DELETE CASCADE,
|
||||
problem_id INTEGER NOT NULL,
|
||||
title_ko TEXT,
|
||||
title_en TEXT,
|
||||
level INTEGER,
|
||||
tags TEXT[] DEFAULT NULL,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workbook_id, problem_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workbook_problems_workbook
|
||||
ON workbook_problems(workbook_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbook_sends (
|
||||
workbook_id BIGINT NOT NULL REFERENCES workbooks(id) ON DELETE CASCADE,
|
||||
problem_id INTEGER NOT NULL,
|
||||
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workbook_id, problem_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workbook_sends_workbook
|
||||
ON workbook_sends(workbook_id);
|
||||
```
|
||||
|
||||
### 테이블 설명
|
||||
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `workbooks` | 문제집 메타 (BOJ workbook id를 PK로 사용) |
|
||||
| `workbook_problems` | 문제집-문제 매핑 + 메타데이터 (제목, 난이도, 태그) |
|
||||
| `workbook_sends` | 발송 기록. 중복 추천 방지의 핵심 |
|
||||
|
||||
## 환경변수
|
||||
|
||||
필수:
|
||||
- `DATABASE_URL` - PostgreSQL 연결 URL (미설정 시 앱 시작 불가)
|
||||
|
||||
Admin API 사용 시:
|
||||
- `ADMIN_PASSWORD` - `X-Admin-Password` 헤더와 비교
|
||||
|
||||
검색 기본값 커스터마이징:
|
||||
- `SOURCE_MODE_DEFAULT`, `WORKBOOK_ID_DEFAULT`
|
||||
- `DIFFICULTY_MODE_DEFAULT`, `TAG_MODE_DEFAULT`, `LANG_DEFAULT`
|
||||
- `DIFFICULTY_EASY`/`HARD`/`ALL`, `TAGS_EASY`/`HARD`/`ALL`
|
||||
- `TAG_PICK`, `TAG_PICK_EASY`/`HARD`/`ALL`, `TAGS_JOIN`
|
||||
94
docs/OPERATIONS.md
Normal file
94
docs/OPERATIONS.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Operations Guide
|
||||
|
||||
## 배포 방식
|
||||
|
||||
### Docker (수동)
|
||||
|
||||
```bash
|
||||
docker build -f dockerfile -t baekjoon-bot:prod .
|
||||
|
||||
docker run -d --name baekjoon-bot \
|
||||
--env-file .env \
|
||||
-p 8000:8000 \
|
||||
baekjoon-bot:prod
|
||||
```
|
||||
|
||||
### CI/CD (Gitea Actions)
|
||||
|
||||
Workflow: `.gitea/workflows/cicd.yml`
|
||||
|
||||
**트리거**: `main` 브랜치 push (문서 파일만 변경된 경우 스킵)
|
||||
|
||||
**파이프라인**:
|
||||
1. 수동 checkout (Gitea `/gitea` 서브패스 대응)
|
||||
2. Docker Hub 로그인
|
||||
3. 이미지 빌드/푸시: `${DOCKERHUB_USERNAME}/baekjoon-bot:latest`
|
||||
4. 서버 배포: `/nkeysworld/compose.apps.yml`에서 `baekjoon-bot` 서비스 pull/up
|
||||
5. 정리: `docker image prune -f`
|
||||
6. Discord webhook 알림 (성공/실패)
|
||||
|
||||
**필요 Secrets**:
|
||||
- `NKEY_PAT` - Gitea repo fetch 인증
|
||||
- `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` - Docker Hub 인증
|
||||
- `DISCORD_WEBHOOK` - 알림 전송
|
||||
|
||||
**서버 요구사항**:
|
||||
- `docker`, `docker compose` 실행 가능
|
||||
- `/nkeysworld/compose.apps.yml` 파일 존재
|
||||
- compose 내 서비스명 `baekjoon-bot` 존재
|
||||
|
||||
## 운영 체크리스트
|
||||
|
||||
- `DATABASE_URL` 환경변수 필수
|
||||
- Admin API 사용 시 `ADMIN_PASSWORD` 설정 필수
|
||||
- 인바운드: TCP 8000
|
||||
- 아웃바운드: solved.ac API, acmicpc.net 접근 필요
|
||||
- DB 스키마는 별도 준비 필요 (레포에 DDL 미포함, `docs/DEVELOPMENT.md` 참고)
|
||||
- Uvicorn stdout 기반 로깅
|
||||
|
||||
## 헬스체크
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8000/
|
||||
# {"status": "ok"}
|
||||
```
|
||||
|
||||
## 로그 확인
|
||||
|
||||
```bash
|
||||
docker logs -f baekjoon-bot
|
||||
```
|
||||
|
||||
## 장애 대응
|
||||
|
||||
### 1. DATABASE_URL 누락
|
||||
- **증상**: 앱 시작 시 `DATABASE_URL is required...`로 즉시 종료
|
||||
- **조치**: `.env` 또는 환경변수에 `DATABASE_URL` 설정
|
||||
|
||||
### 2. 포트 충돌
|
||||
- **증상**: `bind: address already in use`
|
||||
- **조치**: `-p 18000:8000` 등으로 호스트 포트 변경
|
||||
|
||||
### 3. DB 연결 실패
|
||||
- **증상**: API 호출 시 500, 로그에 DB 커넥션 에러
|
||||
- **조치**: URL 확인, DB 접근 허용, DB 상태 확인
|
||||
|
||||
### 4. Admin 인증 실패
|
||||
- **증상**: admin API 403 `invalid admin password`
|
||||
- **조치**: `ADMIN_PASSWORD` 값과 `X-Admin-Password` 헤더 일치 확인
|
||||
|
||||
### 5. 문제집 소진
|
||||
- **증상**: 409 `no_more_problems_in_workbook`
|
||||
- **조치**: `DELETE /admin/workbooks/{id}/reset`으로 초기화하거나 다른 문제집 사용
|
||||
|
||||
## 보안
|
||||
|
||||
- Admin API는 `X-Admin-Password` 헤더 기반 단일 비밀번호 인증
|
||||
- `ADMIN_PASSWORD` 미설정 시 admin API는 500 반환 (의도적 차단)
|
||||
- `.env` 파일은 `.gitignore`에 포함
|
||||
|
||||
## 롤백
|
||||
|
||||
CI/CD는 `:latest` 태그만 사용하므로, 롤백이 필요한 경우:
|
||||
- compose 파일에서 특정 태그로 고정하거나
|
||||
- 태그 전략을 도입해야 한다
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
12
utils.py
12
utils.py
@@ -5,6 +5,8 @@ from typing import Optional, Tuple, List
|
||||
|
||||
import requests
|
||||
|
||||
from fastapi import HTTPException, Header
|
||||
|
||||
# ====== HTTP Session ======
|
||||
SESSION = requests.Session()
|
||||
SESSION.headers.update({"User-Agent": "baekjoon-n8n-bot/1.0"})
|
||||
@@ -13,6 +15,16 @@ SESSION.headers.update({"User-Agent": "baekjoon-n8n-bot/1.0"})
|
||||
KNOWN_LANGS = ["ko", "en", "ja", "ru", "zh", "de", "fr", "es", "pt", "it"]
|
||||
|
||||
|
||||
def require_admin(x_admin_password: str | None = Header(default=None, alias="X-Admin-Password")):
|
||||
expected = env("ADMIN_PASSWORD", "")
|
||||
if not expected:
|
||||
raise HTTPException(status_code=500, detail="ADMIN_PASSWORD is not configured")
|
||||
|
||||
if not x_admin_password or x_admin_password != expected:
|
||||
raise HTTPException(status_code=403, detail="invalid admin password")
|
||||
|
||||
|
||||
|
||||
def fetch_json_with_retry(url: str, params: dict, retries: int = 3, timeout=(3.05, 10)) -> dict:
|
||||
last_err = None
|
||||
for i in range(retries):
|
||||
|
||||
@@ -7,7 +7,7 @@ async def pick_from_workbook(
|
||||
db: AsyncSession,
|
||||
workbook_id: int,
|
||||
pick: str = "random", # random | level_asc
|
||||
) -> Tuple[Optional[int], Optional[str], Optional[int]]:
|
||||
) -> Tuple[Optional[int], Optional[str], Optional[int], Optional[int], Optional[int]]:
|
||||
"""
|
||||
workbook_id에서 아직 보내지 않은 문제 1개 선택 + workbook_sends 기록까지 원샷.
|
||||
pick:
|
||||
@@ -46,17 +46,27 @@ async def pick_from_workbook(
|
||||
FROM candidate
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING problem_id
|
||||
),
|
||||
total AS (
|
||||
SELECT COUNT(*) AS cnt FROM workbook_problems WHERE workbook_id = :wid
|
||||
),
|
||||
sent AS (
|
||||
SELECT COUNT(*) AS cnt FROM workbook_sends WHERE workbook_id = :wid
|
||||
)
|
||||
SELECT problem_id, title, level
|
||||
SELECT candidate.problem_id, candidate.title, candidate.level,
|
||||
(SELECT cnt FROM sent) + 1 AS current_idx,
|
||||
(SELECT cnt FROM total) AS total_cnt
|
||||
FROM candidate;
|
||||
"""
|
||||
|
||||
row = (await db.execute(text(sql), {"wid": workbook_id})).first()
|
||||
if not row:
|
||||
return None, None, None
|
||||
return None, None, None, None, None
|
||||
|
||||
await db.commit()
|
||||
pid = int(row[0])
|
||||
title = str(row[1])
|
||||
level = int(row[2]) if row[2] is not None else None
|
||||
return pid, title, level
|
||||
current_idx = int(row[3])
|
||||
total_cnt = int(row[4])
|
||||
return pid, title, level, current_idx, total_cnt
|
||||
|
||||
Reference in New Issue
Block a user