# CLAUDE.md — web-backend 프로젝트 가이드 > Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서. --- ## 1. 프로젝트 개요 Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포. - **서비스**: lotto-lab, stock, travel-proxy, music-lab, insta-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개) - **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포 - **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포 --- ## 2. NAS 환경 | 항목 | 값 | |------|----| | 장비 | Synology NAS | | CPU | Intel Celeron J4025 (2 Core, 2.0 GHz) | | 메모리 | 18 GB | | Docker | Synology Container Manager | | Git 서버 | Gitea (self-hosted, NAS 내부) | | AI 서버 | Windows PC (192.168.45.59:8000) — NVIDIA RTX 5070 Ti (16GB VRAM) + Ollama | --- ## 3. NAS 디렉토리 구조 ``` /volume1 ├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치) │ ├── lotto/ # lotto 소스 (rsync 동기화) │ ├── stock/ # stock 소스 (rsync 동기화) │ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화) │ ├── deployer/ # deployer 소스 (rsync 동기화) │ ├── nginx/default.conf # Nginx 설정 │ ├── scripts/deploy.sh # Webhook 트리거 배포 스크립트 │ ├── docker-compose.yml │ ├── .env # 운영 환경변수 │ ├── data/lotto.db # SQLite DB │ └── data/music/ # 생성된 오디오 파일 (music-lab) │ ├── workspace/web-page-backend/ # Git 레포 클론 위치 (REPO_PATH) │ └── web/images/webPage/travel/ # 원본 여행 사진 (RO 마운트) ``` --- ## 4. Docker 서비스 & 포트 | 컨테이너 | 포트 | 역할 | |---------|------|------| | `lotto` | 18000 | 로또 데이터 수집·분석·추천 API | | `stock` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 | | `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API | | `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드) | | `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API | | `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) | | `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) | | `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) | | `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 | | `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 | | `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 | --- ## 5. Nginx 라우팅 규칙 | 경로 | 프록시 대상 | 비고 | |------|------------|------| | `/api/` | `lotto:8000` | lotto API (기본) | | `/api/travel/` | `travel-proxy:8000` | travel API | | `/api/stock/` | `stock:8000` | stock API | | `/api/trade/` | `stock:8000` | KIS 실계좌 API | | `/api/portfolio` | `stock:8000` | trailing slash 유무 모두 매칭 | | `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API | | `/api/insta/` | `insta-lab:8000` | 인스타 카드 자동 생성 API | | `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API | | `/api/todos` | `personal:8000` | 투두 API | | `/api/blog/` | `personal:8000` | 블로그 API | | `/api/profile/` | `personal:8000` | 포트폴리오 API | | `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket | | `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) | | `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook | | `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 | | `/media/videos/` | `/data/videos/` (파일 직접 서빙) | YouTube 영상 MP4 | | `/media/travel/.thumb/` | `/data/thumbs/` (파일 직접 서빙) | 썸네일 캐시 | | `/media/travel/` | `/data/travel/` (파일 직접 서빙) | 원본 사진 | | `/assets/` | 정적 파일 (장기 캐시) | Vite 해시 파일 | | `/` | SPA fallback (`try_files → index.html`) | | --- ## 6. 기술 스택 | 레이어 | 기술 | |--------|------| | Backend 언어 | Python 3.12 | | API 프레임워크 | FastAPI | | DB | SQLite (`/app/data/*.db`) | | 스케줄러 | APScheduler | | 컨테이너 | Docker (`python:3.12-slim` 기반) | | AI 연동 | Ollama (Llama 3.1) — Windows PC (192.168.45.59) | | 주식 API | KIS (한국투자증권) Open API | --- ## 7. 자동 배포 흐름 ``` 개발자 git push → Gitea → Webhook (HMAC SHA256 검증) → deployer 컨테이너 → /scripts/deploy.sh → rsync(REPO→RUNTIME) → docker compose up -d --build ``` - **배포 스크립트 위치**: `scripts/deploy-nas.sh` (레포) / `scripts/deploy.sh` (런타임) - **환경변수 파일**: `.env` (RUNTIME_PATH, REPO_PATH, PHOTO_PATH, PUID, PGID 등) - **백업**: `.releases/` 디렉토리에 자동 백업 --- ## 8. 로컬 개발 환경 ```bash # .env 기본값으로 즉시 실행 가능 (RUNTIME_PATH=., PHOTO_PATH=./mock_data/photos) docker compose up -d ``` | 서비스 | 로컬 URL | |--------|----------| | Frontend + API | http://localhost:8080 | | Lotto Backend | http://localhost:18000 | | Travel API | http://localhost:19000 | | Stock Lab | http://localhost:18500 | | Insta Lab | http://localhost:18700 | | Realestate Lab | http://localhost:18800 | | Packs Lab | http://localhost:18950 | --- ## 9. 서비스별 핵심 정보 ### lotto-lab (lotto/) - DB: `/app/data/lotto.db` - 데이터 소스: `smok95.github.io/lotto/results/` - 파일 구조: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `utils.py`, `purchase_manager.py`, `strategy_evolver.py` **lotto.db 테이블** | 테이블 | 설명 | |--------|------| | `draws` | 로또 당첨번호 | | `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) | | `simulation_runs` | 시뮬레이션 실행 기록 | | `simulation_candidates` | 시뮬레이션 후보 (점수 5종) | | `best_picks` | 현재 활성 최적 번호 20개 (`is_active` 플래그로 교체) | | `purchase_history` | 구매 이력 (실제/가상, 번호, 전략 출처, 결과) | | `strategy_performance` | 전략별 회차 성과 (EMA 입력 데이터) | | `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) | | `weekly_reports` | 주간 공략 리포트 캐시 | | `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) | | `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 | | `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 | **스케줄러 job** - 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest` → `check_results_for_draw`) - 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체) **lotto-lab API 목록** | 메서드 | 경로 | 설명 | |--------|------|------| | GET | `/api/lotto/latest` | 최신 당첨번호 | | GET | `/api/lotto/{drw_no}` | 특정 회차 | | GET | `/api/lotto/stats` | 번호 빈도 통계 | | GET | `/api/lotto/analysis` | 5가지 통계 분석 리포트 | | GET | `/api/lotto/best` | 시뮬레이션 최적 번호 (기본 20쌍) | | GET | `/api/lotto/simulation` | 시뮬레이션 상세 결과 | | GET | `/api/lotto/recommend` | 통계 기반 추천 | | GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 | | GET | `/api/lotto/recommend/batch` | 배치 추천 | | POST | `/api/lotto/recommend/batch` | 배치 추천 저장 | | GET | `/api/lotto/recommend/smart` | 전략 진화 기반 메타 추천 | | GET | `/api/lotto/purchase` | 구매 이력 조회 (is_real, strategy, draw_no, days 필터) | | POST | `/api/lotto/purchase` | 구매 등록 (실제/가상, 번호, 전략 출처 포함) | | PUT | `/api/lotto/purchase/{id}` | 구매 이력 수정 | | DELETE | `/api/lotto/purchase/{id}` | 구매 이력 삭제 | | GET | `/api/lotto/purchase/stats` | 구매 통계 (전체/실제/가상 + 전략별) | | GET | `/api/lotto/strategy/weights` | 전략별 가중치 + 성과 + trend | | GET | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용) | | POST | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 | | POST | `/api/admin/simulate` | 시뮬레이션 수동 실행 | | POST | `/api/admin/sync_latest` | 당첨번호 수동 동기화 | | GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) | | PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 | | DELETE | `/api/history/{id}` | 삭제 | | GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 피처 | | GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차) | | GET | `/api/lotto/curator/usage` | 큐레이터 토큰·비용 집계 | | POST | `/api/lotto/briefing` | AI 브리핑 저장 | | GET | `/api/lotto/briefing/latest` | 최신 브리핑 | | GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 | | GET | `/api/lotto/briefing` | 브리핑 이력 | ### stock (stock/) - Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000` - KIS API 연동으로 실계좌 잔고·거래 조회 - 뉴스 스크래핑: 네이버 증권 + 해외 사이트 - DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블) - 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json` **stock API 목록** | 메서드 | 경로 | 설명 | |--------|------|------| | GET | `/api/stock/news` | 뉴스 조회 (`limit`, `category` 파라미터) | | GET | `/api/stock/indices` | 주요 지표 실시간 조회 | | POST | `/api/stock/scrap` | 수동 뉴스 스크랩 트리거 | | GET | `/api/trade/balance` | 실계좌 잔고 조회 (Windows AI 서버 프록시) | | POST | `/api/trade/order` | 주식 주문 (Windows AI 서버 프록시) | | GET | `/api/portfolio` | 포트폴리오 전체 조회 (현재가·손익·예수금 포함) | | POST | `/api/portfolio` | 종목 추가 | | PUT | `/api/portfolio/{id}` | 종목 수정 | | DELETE | `/api/portfolio/{id}` | 종목 삭제 | | GET | `/api/portfolio/cash` | 예수금 전체 조회 | | PUT | `/api/portfolio/cash` | 예수금 등록·수정 (upsert) | | DELETE | `/api/portfolio/cash/{broker}` | 예수금 삭제 | | POST | `/api/portfolio/snapshot` | 총 자산 스냅샷 수동 저장 | | GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 조회 (`days=0`: 전체, `days=N`: 최근 N건) | | GET | `/api/portfolio/sell-history` | 매도 내역 조회 (`broker`, `days` 필터 선택) | | POST | `/api/portfolio/sell-history` | 매도 기록 저장 (id 포함 레코드 반환) | | PUT | `/api/portfolio/sell-history/{id}` | 매도 기록 수정 (수정된 레코드 반환) | | DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 삭제 | **매도 히스토리 (`sell_history`)** - 독립 테이블 — `portfolio` 테이블과 별개로 관리 - `sold_at`: UTC ISO8601 형식 (`new Date().toISOString()`) - `realized_profit` / `realized_rate`: 프론트 계산값 저장 (백엔드 재계산 무방) - 응답 정렬: `sold_at DESC` (최신순) **총 자산 스냅샷 (`asset_snapshots`)** - 평일 15:40 APScheduler 자동 실행 (`save_daily_snapshot`) - 공휴일 판별: `holidays.json` (매년 수동 갱신, KRX 기준) → `is_market_open()` 함수 - 같은 날 중복 저장 시 upsert (date UNIQUE 제약) - 수동 저장: `POST /api/portfolio/snapshot` - 이력 조회: `GET /api/portfolio/snapshot/history?days=30` (ASC 정렬, 차트용) **스케줄러 job** - 08:00 매일 — 뉴스 스크랩 (`run_scraping_job`) - 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`) ### music-lab (music-lab/) - 듀얼 프로바이더 음악 생성 서비스 (Suno API + 로컬 MusicGen) + YouTube 영상 제작 + 시장 조사 트렌드 - 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙) - 생성된 영상 파일: `/app/data/videos/` (Nginx가 `/media/videos/`로 직접 서빙) - DB: `/app/data/music.db` (music_tasks, music_library, video_projects, revenue_records, market_trends, trend_reports 테이블) - 파일 구조: `main.py`, `db.py`, `suno_provider.py`, `local_provider.py`, `video_producer.py`, `market.py` - 생성 흐름: POST generate (provider 지정) → task_id 반환 → BackgroundTask → 파일 저장 → 라이브러리 자동 등록 **Provider 구조** - `suno`: Suno REST API (`apicast.suno.ai/v1`) — 보컬·가사·인스트루멘탈 지원 - `local`: Windows AI 서버 (MusicGen) — 인스트루멘탈 전용 **music-lab API 목록** | 메서드 | 경로 | 설명 | |--------|------|------| | GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 | | GET | `/api/music/models` | Suno 모델 목록 (V4~V5.5) | | GET | `/api/music/credits` | Suno 크레딧 조회 | | POST | `/api/music/generate` | 음악 생성 (provider, model, vocal_gender, negative_tags, style_weight, audio_weight) | | GET | `/api/music/status/{task_id}` | 생성 상태 폴링 | | POST | `/api/music/lyrics` | Suno AI 가사 생성 | | GET | `/api/music/library` | 라이브러리 전체 조회 | | POST | `/api/music/library` | 트랙 수동 추가 | | DELETE | `/api/music/library/{id}` | 트랙 삭제 | | POST | `/api/music/extend` | 곡 연장 | | POST | `/api/music/vocal-removal` | 보컬/인스트 분리 (2트랙) | | POST | `/api/music/cover-image` | 커버 이미지 2장 생성 | | POST | `/api/music/wav` | WAV 고음질 변환 | | POST | `/api/music/stem-split` | 12스템 분리 (50cr) | | GET | `/api/music/timestamped-lyrics` | 타임스탬프 가사 (가라오케) | | POST | `/api/music/style-boost` | AI 스타일 프롬프트 생성 | | POST | `/api/music/upload-cover` | 외부 음원 AI Cover | | POST | `/api/music/upload-extend` | 외부 음원 확장 | | POST | `/api/music/add-vocals` | 인스트에 AI 보컬 추가 | | POST | `/api/music/add-instrumental` | 보컬에 AI 반주 추가 | | POST | `/api/music/video` | 뮤직비디오 MP4 생성 | | GET | `/api/music/lyrics/library` | 저장된 가사 목록 | | POST | `/api/music/lyrics/library` | 가사 저장 | | PUT | `/api/music/lyrics/library/{id}` | 가사 수정 | | DELETE | `/api/music/lyrics/library/{id}` | 가사 삭제 | | POST | `/api/music/video-project` | 영상 프로젝트 생성 (track_id, format, target_countries) | | GET | `/api/music/video-projects` | 영상 프로젝트 목록 | | GET | `/api/music/video-project/{id}` | 영상 프로젝트 상세 | | POST | `/api/music/video-project/{id}/render` | FFmpeg 렌더링 시작 (BackgroundTask) | | GET | `/api/music/video-project/{id}/export` | 내보내기 패키지 (mp4+thumbnail+metadata.json) | | DELETE | `/api/music/video-project/{id}` | 영상 프로젝트 삭제 | | GET | `/api/music/revenue/dashboard` | 수익 대시보드 (총수익·조회수·가중평균 RPM) | | GET | `/api/music/revenue` | 수익 기록 목록 | | POST | `/api/music/revenue` | 수익 기록 추가 (UNIQUE: yt_video_id+record_month+country) | | PUT | `/api/music/revenue/{id}` | 수익 기록 수정 | | DELETE | `/api/music/revenue/{id}` | 수익 기록 삭제 | | POST | `/api/music/market/ingest` | agent-office 트렌드 수신 + 리포트 생성 | | GET | `/api/music/market/trends` | 트렌드 조회 (country, genre, source, days=7) | | GET | `/api/music/market/report/latest` | 최신 트렌드 리포트 | | GET | `/api/music/market/report` | 트렌드 리포트 목록 (limit=10) | | GET | `/api/music/market/suggest` | Suno 프롬프트 추천 (limit=5) | **환경변수** - `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화) - `MUSIC_AI_SERVER_URL`: 로컬 MusicGen 서버 URL (미설정 시 local provider 비활성화) - `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`) - `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`) - `PEXELS_API_KEY`: Pexels 스톡 이미지 API 키 (미설정 시 슬라이드쇼 Pexels 이미지 비활성화) - `ANTHROPIC_API_KEY`: Claude Haiku — YouTube 메타데이터 생성 + 시장 인사이트 (미설정 시 폴백 텍스트) - `VIDEO_DATA_DIR`: 영상 파일 저장 경로 (기본 `/app/data/videos`) **video_projects 테이블** - format: `visualizer` | `slideshow` - status: `pending` → `rendering` → `done` | `failed` - target_countries: JSON 배열 (예: `["BR","US"]`) - render_params: JSON 객체 (FFmpeg 파라미터 캐시) **revenue_records 테이블** - UNIQUE(yt_video_id, record_month, country) - avg_rpm 계산: 가중평균 `SUM(revenue_usd)/SUM(views)*1000` (단순 AVG 아님) **market_trends 테이블** - source: `youtube` | `google_trends` | `billboard` - metadata: JSON 객체 (원본 API 응답 부분) - 인덱스: `idx_mt_country_source` ON (country, source, collected_at DESC) **trend_reports 테이블** - report_date UNIQUE — 같은 날 두 번 ingest 시 upsert - top_genres: JSON 배열 `[{genre, score, countries}]` (최대 10개, score 내림차순) - recommended_styles: JSON 배열 `[{genre, suno_prompt, target_countries, reason}]` (최대 5개) **music_library 테이블 (확장 컬럼)** - `provider`: `suno` | `local` — 생성에 사용된 프로바이더 - `lyrics`: Suno 생성 가사 텍스트 - `image_url`: Suno 생성 커버 이미지 URL - `suno_id`: Suno 곡 ID (CDN 참조용) - `file_hash`: MD5 해시 (rename 감지용) - `cover_images`: JSON 배열 — 커버 이미지 URL 목록 - `wav_url`: WAV 변환 URL - `video_url`: 뮤직비디오 URL - `stem_urls`: JSON 객체 — 12스템 URL 맵 **Suno 생성 특이사항** - 1회 생성 시 2개 변형(variation) 반환 → 둘 다 라이브러리에 저장 - CDN URL(`cdn1.suno.ai`)은 임시 → 반드시 로컬 다운로드 필요 - 가사 섹션 태그: `[Verse]`, `[Chorus]`, `[Bridge]`, `[Instrumental]` 등 ### realestate-lab (realestate-lab/) - 공공데이터포털 API 연동: 한국부동산원 청약홈 분양정보 조회 + 자치구 5티어 매칭 + agent-office push 알림 - DB: `/app/data/realestate.db` (announcements, announcement_models, user_profile, match_results, collect_log 테이블) - 파일 구조: `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py` **환경변수** - `DATA_GO_KR_API_KEY`: 공공데이터포털 API 키 (미설정 시 수동 등록만 가능) - `AGENT_OFFICE_URL`: agent-office 내부 URL (기본 `http://agent-office:8000`) — 신규 매칭 push 대상 - `REALESTATE_NOTIFY_TIMEOUT`: agent-office push timeout 초 (기본 15) **스케줄러 job (`scheduled_collect` 4단계 흐름)** - 09:00 매일 — `collect → cleanup → match → notify` 1. `collect_all()` — 모집공고일 30일 윈도우(`RCRIT_PBLANC_DE_FROM`) 사전 좁힘 + 자치구 추출 + status='완료' skip 2. `delete_old_completed_announcements(grace_days=90)` — `winner_date + 90일` 경과한 완료 공고 정리 (FK CASCADE로 match_results도 삭제) 3. `run_matching()` — 자치구 5티어 가중치 + 자격 곡선 적용 4. `notify_new_matches()` — `notified_at IS NULL AND match_score >= profile.min_match_score AND profile.notify_enabled`인 매칭을 agent-office로 push - 00:00 매일 — 상태 갱신 + 재매칭 (`scheduled_status_update`, notifier 미호출) **매칭 점수 모델 (총 100점)** - 지역 35점 — 광역 매칭 시 10점 + 자치구 5티어 가중치(S=25 / A=20 / B=15 / C=10 / D=5) - `preferred_districts`가 모든 티어 비어있으면 광역 매칭만으로 35점 풀 점수 (legacy 호환) - 주택유형 10점 — `preferred_types`에 매칭 (binary) - 면적 15점 — `[min_area, max_area]` 범위 안 모델 1개 이상 (binary) - 가격 15점 — `max_price` 이하 모델 1개 이상 (binary) - 자격 25점 — `_check_eligible_types()` 결과 1개 이상이면 15점 + 추가당 5점, 최대 +10 - reasons 텍스트 예시: `"자치구 S티어: 강남구 (+25)"`, `"광역 일치: 서울"`, `"선호 지역 일치: 서울"` (legacy) **user_profile 신규 컬럼 (Task 2026-04-28 마이그레이션)** - `preferred_districts` TEXT — JSON `{"S":[...], "A":[...], "B":[...], "C":[...], "D":[...]}`. default `'{}'` - `min_match_score` INTEGER — 알림 임계값. default 70 - `notify_enabled` INTEGER — 알림 ON/OFF. default 1 **announcements / match_results 신규 컬럼** - `announcements.district` TEXT + `idx_ann_district` 인덱스 — collector가 주소/region_name에서 정규식 파싱 - `match_results.notified_at` TEXT NULL — agent-office push 성공 시 timestamp 기록 (멱등 마킹) **notifier.py 흐름** 1. `get_profile()` → `notify_enabled=False`면 skip, `min_match_score` 가져옴 2. `get_unnotified_matches(min_score)` — JOIN으로 announcements 정보 포함 (district, status, receipt 등) 3. `POST {AGENT_OFFICE_URL}/api/agent-office/realestate/notify` body=`{"matches": [...]}` 4. 응답 `{sent_ids: [...]}` → `mark_matches_notified(sent_ids)` (notified_at = now) 5. RequestException 시 마킹 안 함 → 다음 사이클 재시도 **realestate-lab API 목록** | 메서드 | 경로 | 설명 | |--------|------|------| | GET | `/api/realestate/announcements` | 공고 목록. 응답에 `district`, `match_score`, `match_reasons`, `eligible_types` 포함 | | GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 + district 포함) | | POST | `/api/realestate/announcements` | 수동 공고 등록 | | PUT | `/api/realestate/announcements/{id}` | 공고 수정 | | PATCH | `/api/realestate/announcements/{id}/bookmark` | 북마크 토글 (텔레그램 인라인 키보드 콜백 대상) | | DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 | | DELETE | `/api/realestate/announcements/closed` | status='완료' 공고 일괄 삭제 | | POST | `/api/realestate/collect` | 수동 수집 트리거 (collect → cleanup → match → notify 전체 흐름) | | GET | `/api/realestate/collect/status` | 마지막 수집 결과 | | GET | `/api/realestate/profile` | 내 프로필 조회 (`preferred_districts`, `min_match_score`, `notify_enabled` 포함) | | PUT | `/api/realestate/profile` | 프로필 수정 (upsert). body에 `preferred_districts: {S:[],...}`, `min_match_score: 0~100`, `notify_enabled: bool` 수용 | | GET | `/api/realestate/matches` | 매칭 결과 목록 (응답에 `district`, `status` 포함) | | POST | `/api/realestate/matches/refresh` | 매칭 재계산 | | PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 | | GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) | ### travel-proxy (travel-proxy/) - 원본 사진: `/data/travel/` (RO) - 썸네일 캐시: `/data/thumbs/` (RW) - DB: `/data/thumbs/travel.db` (photos, album_covers 테이블) - 메타: `/data/travel/_meta/region_map.json`, `regions.geojson` - 지역 오버라이드: `/data/thumbs/region_map_extra.json` (RW, `_regions_meta` 포함) - 파일 구조: `main.py`, `db.py`, `indexer.py` - 썸네일: 480×480 리사이징 (Pillow), 동기화 시 사전 생성 + 온디맨드 폴백 - 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성 **travel.db 테이블** | 테이블 | 설명 | |--------|------| | `photos` | 사진 인덱스 (album, filename, mtime, has_thumb) | | `album_covers` | 앨범별 커버 사진 지정 | **지역 관리 아키텍처** - `region_map.json` (RO): 원본 지역→앨범 매핑 (`_meta/` 안에 위치) - `region_map_extra.json` (RW): 사용자 수정분 오버라이드 (앨범 이동, 신규 지역) - `_regions_meta`: 커스텀 지역의 이름·좌표 저장 (`{ "region_id": { "name": "...", "coordinates": [lng, lat] } }`) - `regions.geojson` (RO): GeoJSON Polygon 지역 경계 - 커스텀 지역: `GET /api/travel/regions`에서 `region_map`에 있지만 GeoJSON에 없는 지역을 자동 추가 (Point geometry 또는 null) **travel-proxy API 목록** | 메서드 | 경로 | 설명 | |--------|------|------| | GET | `/api/travel/regions` | 지역 GeoJSON (커스텀 지역 동적 추가 포함) | | GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) | | POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 | | GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 + region/regionName | | PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 | | PUT | `/api/travel/albums/{album}/region` | 앨범 지역 변경 (region_map_extra 수정) | | PUT | `/api/travel/regions/{region_id}` | 커스텀 지역 이름/좌표 수정 (지도 핀 표시용) | ### insta-lab (insta-lab/) - 인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피 + PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드 - DB: `/app/data/insta.db` (news_articles, trending_keywords, card_slates, card_assets, generation_tasks, prompt_templates) - 카드 사이즈: 1080×1350 (인스타 4:5 세로) - 카드 렌더: Jinja2 템플릿 → Playwright headless Chromium 스크린샷 - 파일 구조: `app/main.py`, `config.py`, `db.py`, `news_collector.py`, `keyword_extractor.py`, `card_writer.py`, `card_renderer.py`, `templates/default/card.html.j2` **환경변수** - `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET`: 네이버 검색 API - `ANTHROPIC_API_KEY`: Claude API (Haiku=키워드 정제, Sonnet=카드 카피) - `ANTHROPIC_MODEL_HAIKU` / `ANTHROPIC_MODEL_SONNET`: 모델명 오버라이드 - `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`) - `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`) - `NEWS_PER_CATEGORY` / `KEYWORDS_PER_CATEGORY`: 수집·추출 limit 튜닝 **카테고리 시드 키워드** - 기본 economy / psychology / celebrity 3종 (config.DEFAULT_CATEGORY_SEEDS) - `prompt_templates.name='category_seeds'`에 JSON으로 오버라이드 가능 **카드 슬레이트 (`card_slates`)** - status: `draft` → `rendered` → `sent` (또는 `failed`) - cover_copy / body_copies (8개) / cta_copy / suggested_caption / hashtags JSON 컬럼 - accent_color는 카테고리별 기본값 (economy=#0F62FE, psychology=#A66CFF, celebrity=#FF5C8A) **스케줄러 job (agent-office)** - 09:30 매일 — `_run_insta_schedule` (insta_pipeline) → 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시 - `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송 **insta-lab API 목록** | 메서드 | 경로 | 설명 | |--------|------|------| | GET | `/api/insta/status` | 서비스 상태 (NAVER/ANTHROPIC 키 여부) | | POST | `/api/insta/news/collect` | 뉴스 수집 트리거 (BackgroundTask) | | GET | `/api/insta/news/articles` | 수집 기사 목록 (category, days) | | POST | `/api/insta/keywords/extract` | 키워드 추출 트리거 (BackgroundTask) | | GET | `/api/insta/keywords` | 트렌딩 키워드 목록 (category, used) | | POST | `/api/insta/slates` | 슬레이트 생성 (keyword, category) | | GET | `/api/insta/slates` | 슬레이트 목록 | | GET | `/api/insta/slates/{id}` | 슬레이트 상세 + 자산 | | POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 | | GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) | | DELETE | `/api/insta/slates/{id}` | 슬레이트 삭제 (자산 파일 포함) | | GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 | | GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 CRUD | ### agent-office (agent-office/) - AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행 - stock/music-lab/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음) - 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`) - 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드) - 청약 매칭 알림: realestate-lab이 신규 매칭 발견 시 push → `RealestateAgent.on_new_matches()` → 텔레그램 1통(인라인 [🔖 북마크]/[📄 공고] 또는 [전체 보기] 버튼) - DB: `/app/data/agent_office.db` (agent_config, agent_tasks, agent_logs, telegram_state 테이블) - 파일 구조: `main.py`, `db.py`, `config.py`, `models.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/base.py`, `agents/stock.py`, `agents/music.py`, `agents/realestate.py`, `telegram/realestate_message.py` **에이전트 FSM 상태**: idle → working → waiting (승인 대기) → reporting → break (휴식) **환경변수** - `STOCK_URL`: stock 내부 URL (기본 `http://stock:8000`) - `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`) - `REALESTATE_LAB_URL`: realestate-lab 내부 URL (기본 `http://realestate-lab:8000`) — 북마크 콜백 프록시 대상 - `REALESTATE_DASHBOARD_URL`: 텔레그램 [전체 보기] 버튼 URL (기본 `http://localhost:8080/realestate`) - `TELEGRAM_BOT_TOKEN`: 텔레그램 봇 토큰 (미설정 시 알림 비활성화) - `TELEGRAM_CHAT_ID`: 텔레그램 채팅 ID - `TELEGRAM_WEBHOOK_URL`: 텔레그램 Webhook URL - `TELEGRAM_WIFE_CHAT_ID`: 아내 chat.id (브리핑 공유 + 대화 허용) - `ANTHROPIC_API_KEY`: 자연어 대화용 Claude API 키 (미설정 시 대화 비활성) - `CONVERSATION_MODEL`: 대화 모델 (기본 `claude-haiku-4-5-20251001`) - `CONVERSATION_HISTORY_LIMIT`: 이력 주입 수 (기본 20) - `CONVERSATION_RATE_PER_MIN`: 채팅당 분당 최대 메시지 (기본 6) - `LOTTO_BACKEND_URL`: 기본 `http://lotto:8000` - `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5` - `YOUTUBE_DATA_API_KEY`: YouTube Data API v3 키 (미설정 시 YouTube trending 수집 skip) **YouTubeResearchAgent (`agents/youtube.py`)** - `agent_id = "youtube"` — AGENT_REGISTRY에 등록 - 09:00 매일 `on_schedule()` → 국가별 YouTube 트렌딩 + Google Trends + Billboard Top20 수집 → music-lab push - `on_command("research", {countries: []})` → 수동 트리거 (백그라운드 asyncio.create_task) - 수집 소스: `youtube_researcher.py` (fetch_youtube_trending, fetch_google_trends, fetch_billboard_top20) - DB: `youtube_research_jobs` 테이블에 실행 이력 기록 - 동시실행 방지: `self.state == "working"` 체크 후 거부 - 월요일 08:00 `send_weekly_report()` → music-lab 최신 리포트 → 텔레그램 발송 **텔레그램 자연어 대화 (옵션 B)** - 슬래시 명령이 아닌 일반 문장을 보내면 Claude Haiku 4.5가 응답 - 프롬프트 캐싱: `system` 블록 + 히스토리 마지막 블록에 `cache_control: ephemeral` → 5분 TTL - 허용 chat_id 화이트리스트: `TELEGRAM_CHAT_ID`, `TELEGRAM_WIFE_CHAT_ID` - 평가 지표: `conversation_messages` 테이블에 tokens / cache_read / cache_write / latency 기록 - 조회: `GET /api/agent-office/conversation/stats?days=7` **스케줄러 job** - 07:30 매일 — 주식 뉴스 요약 (`stock_news_job`) - 매주 월요일 07:00 — 로또 큐레이터 브리핑 (`lotto_curate`) - 60초 간격 — 유휴 에이전트 휴식 체크 (`idle_check_job`) - ~~09:15 매일 — 청약 매칭 데일리 리포트~~ (Task 2026-04-28에서 폐기. realestate-lab의 push 트리거로 전환) - 09:00 매일 — YouTube 트렌드 수집 (`youtube_research`) → music-lab `/api/music/market/ingest` push - 매주 월요일 08:00 — YouTube 주간 리포트 텔레그램 발송 (`youtube_weekly_report`) **RealestateAgent (`agents/realestate.py`)** - 진입점: `on_new_matches(matches: list[dict]) -> {sent, sent_ids, message_id}` - realestate-lab의 push에서 트리거 → `format_realestate_matches()` + `build_match_keyboard()` → `messaging.send_raw()` - 1~2건이면 풀 카드 + [🔖 북마크]/[📄 공고 보기] 행씩, 3건 이상이면 묶음 카드 + [📋 전체 보기] 단일 URL 버튼 - 인라인 키보드 콜백 `realestate_bookmark_{id}` → `webhook.py`의 `_handle_realestate_bookmark` → `service_proxy.realestate_bookmark_toggle()` → realestate-lab의 `PATCH /announcements/{id}/bookmark` - 송신 성공 시 sent_ids 반환 → realestate-lab이 match_results.notified_at 마킹 (멱등) - 실패 시 sent=0/sent_ids=[]/error 반환 → 마킹 안 됨 → 다음 사이클 재시도 - `on_command("fetch_matches")`: 수동 트리거 — service_proxy로 매치 가져와 `on_new_matches` 호출 - `on_schedule`: 폐기 (cron 등록 제거됨) **agent-office API 목록** | 메서드 | 경로 | 설명 | |--------|------|------| | WS | `/api/agent-office/ws` | WebSocket (init, agent_state, task_complete, command_result) | | GET | `/api/agent-office/agents` | 에이전트 목록 | | GET | `/api/agent-office/agents/{id}` | 에이전트 상세 (설정 + 상태) | | PUT | `/api/agent-office/agents/{id}` | 에이전트 설정 수정 | | GET | `/api/agent-office/agents/{id}/tasks` | 에이전트 작업 이력 | | GET | `/api/agent-office/agents/{id}/logs` | 에이전트 로그 | | GET | `/api/agent-office/tasks/pending` | 승인 대기 작업 목록 | | GET | `/api/agent-office/tasks/{id}` | 작업 상세 | | POST | `/api/agent-office/command` | 에이전트에 명령 전송 | | POST | `/api/agent-office/approve` | 작업 승인/거부 | | POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook 수신 (realestate_bookmark_* 콜백 포함) | | POST | `/api/agent-office/realestate/notify` | realestate-lab 전용 push 수신 → 텔레그램 송신 | | GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 | | GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) | | POST | `/api/agent-office/youtube/research` | YouTube 트렌드 수집 수동 트리거 (body: `{countries: []}`) | | GET | `/api/agent-office/youtube/research/status` | 마지막 수집 작업 상태 | ### personal (personal/) - 개인 서비스 (포트폴리오 + 블로그 + 투두 통합) - DB: `/app/data/personal.db` (profile, careers, projects, skills, introductions, todos, blog_posts 테이블) - 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL) - 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py` **환경변수** - `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가) **personal API 목록** | 메서드 | 경로 | 설명 | |--------|------|------| | GET | `/api/profile/public` | 공개 데이터 일괄 조회 | | POST | `/api/profile/auth` | 비밀번호 인증 → 토큰 | | GET | `/api/profile/profile` | 프로필 조회 (인증) | | PUT | `/api/profile/profile` | 프로필 수정 (인증) | | GET | `/api/profile/careers` | 경력 목록 (인증) | | POST | `/api/profile/careers` | 경력 추가 (인증) | | PUT | `/api/profile/careers/{id}` | 경력 수정 (인증) | | DELETE | `/api/profile/careers/{id}` | 경력 삭제 (인증) | | GET | `/api/profile/projects` | 프로젝트 목록 (인증) | | POST | `/api/profile/projects` | 프로젝트 추가 (인증) | | PUT | `/api/profile/projects/{id}` | 프로젝트 수정 (인증) | | DELETE | `/api/profile/projects/{id}` | 프로젝트 삭제 (인증) | | GET | `/api/profile/skills` | 기술 목록 (인증) | | POST | `/api/profile/skills` | 기술 추가 (인증) | | PUT | `/api/profile/skills/{id}` | 기술 수정 (인증) | | DELETE | `/api/profile/skills/{id}` | 기술 삭제 (인증) | | GET | `/api/profile/introductions` | 자기소개 목록 (인증) | | POST | `/api/profile/introductions` | 자기소개 추가 (인증) | | PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) | | DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) | | PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) | | GET | `/api/todos` | 투두 전체 목록 | | POST | `/api/todos` | 투두 생성 | | PUT | `/api/todos/{id}` | 투두 수정 | | DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 | | DELETE | `/api/todos/{id}` | 투두 개별 삭제 | | GET | `/api/blog/posts` | 블로그 글 목록 | | POST | `/api/blog/posts` | 블로그 글 생성 | | PUT | `/api/blog/posts/{id}` | 블로그 글 수정 | | DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 | ### packs-lab (packs-lab/) - NAS 자료 다운로드 자동화 — Synology DSM 공유링크 발급 + 5GB 멀티파트 업로드 수신 - Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음) - DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`) - 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py` - 경로 3분리: `PACK_DATA_PATH`(호스트 OS path, docker volume 좌측) → `PACK_BASE_DIR`(컨테이너 내부, upload 저장 target) → `PACK_HOST_DIR`(DSM API path, Supabase에 저장). 운영 NAS에서 `PACK_HOST_DIR` 미설정 시 sign-link가 컨테이너 경로를 DSM에 전달해 파일을 못 찾음. - ⚠️ **DSM API path 형식**: Synology DSM API는 일반 사용자 권한일 때 `//...` 형식만 인식하고 `/volume1/...` 절대경로는 거부(error 408). 운영 NAS는 반드시 `PACK_HOST_DIR=/docker/webpage/media/packs` (shared folder 시점) 설정. admin 사용자만 `/volume1/...` 사용 가능하나 보안상 권장 안 함. **환경변수** - `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용) - `DSM_VERIFY_SSL`: SSL 검증 (default `true`). LAN IP + self-signed cert 환경에서 IP mismatch 시 `false` 설정 (LAN 내부 통신이라 허용) - `BACKEND_HMAC_SECRET`: Vercel SaaS와 양쪽 공유 시크릿 (HMAC SHA256) - `SUPABASE_URL` / `SUPABASE_SERVICE_KEY`: Supabase pack_files 테이블 접근 (service_role, RLS 우회) - `UPLOAD_TOKEN_TTL_SEC`: admin upload 토큰 TTL (기본 1800초 = 30분) - `PACK_BASE_DIR`: 컨테이너 내부 저장 경로 (기본 `/app/data/packs`) - `PACK_HOST_DIR`: DSM API용 path. **운영 NAS는 `/docker/webpage/media/packs` (shared folder 시점)**. 미설정 시 `PACK_BASE_DIR`로 fallback (DSM 호출 X 환경에서만 안전) - `PACK_DATA_PATH`: docker-compose volume 마운트의 호스트 측 OS 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`) **HMAC 인증 패턴** - Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret)) - Replay 방어: 타임스탬프 ±5분 윈도우 - admin browser → backend upload: `Authorization: Bearer ` (jti 단발성) **packs-lab API 목록** | 메서드 | 경로 | 설명 | |--------|------|------| | POST | `/api/packs/sign-link` | Vercel HMAC → DSM Sharing.create로 4시간 유효 다운로드 URL 발급 | | POST | `/api/packs/admin/mint-token` | Vercel HMAC → 일회성 upload 토큰 발급 (기본 30분 TTL) | | POST | `/api/packs/upload` | Bearer token (single-shot) → multipart 5GB 저장 + Supabase INSERT | | POST | `/api/packs/upload/init` | Bearer token → chunked upload 세션 초기화 (`session_id = jti`, `chunk_max_size` 반환). init만 jti consume | | PUT | `/api/packs/upload/{session_id}/chunk?offset=N` | 동일 Bearer token → 부분파일 append (offset 불일치 시 409 + `X-Current-Offset` 헤더) | | GET | `/api/packs/upload/{session_id}/status` | 동일 Bearer token → `{written, expected_size}` 조회 (재개용) | | POST | `/api/packs/upload/{session_id}/complete` | 동일 Bearer token → 부분파일 rename + Supabase INSERT | | DELETE | `/api/packs/upload/{session_id}` | 동일 Bearer token → 세션 중단 + 부분파일 정리 | | GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) | | DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) | **Chunked upload 흐름 (5GB+ 안정성)** - 같은 mint-token을 init·chunk·status·complete·abort 전체에서 Bearer로 재사용 (jti consume은 init에서만) - 세션 state: 컨테이너 내부 `PACK_BASE_DIR/.uploads/{jti}/meta.json + data.part` - chunk 재시도: 클라이언트는 PUT 응답 헤더 `X-Current-Offset` 또는 `GET /status`로 재개 지점 확인 - 환경변수 `PACK_CHUNK_MAX_SIZE` (기본 64MB) — 너무 크면 nginx buffering 부담, 너무 작으면 RTT 비용 ### deployer (deployer/) - Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용) - `WEBHOOK_SECRET` 환경변수로 시크릿 관리 - Webhook 수신 즉시 `{"ok": True}` 응답 후 BackgroundTask로 배포 실행 - 배포 타임아웃: 10분 (`scripts/deploy.sh`) --- ## 10. 주의사항 - **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리 - **라우트 순서**: `DELETE /api/todos/done`은 `DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (personal 서비스, FastAPI prefix 매칭 순서) - **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입 - **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable) - **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드 - **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함 - **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준) - **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용 - **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`) - **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력 - **insta-lab Playwright**: NAS에서 chromium 빌드는 가능하지만 +500MB 이미지. 메모리 부족 시 카드 렌더 실패 가능 — 한 번에 1슬레이트만 렌더하도록 직렬화됨