Compare commits
36 Commits
e3348da642
...
feat/insta
| Author | SHA1 | Date | |
|---|---|---|---|
| 2270072fe5 | |||
| 15f24dc890 | |||
| 2915f2b697 | |||
| 7640a2b4a8 | |||
| 427522bd1a | |||
| 0bddc5c607 | |||
| 54c677f75a | |||
| 01bb837525 | |||
| 8ceb0af736 | |||
| ecf1f643b2 | |||
| 077d411f83 | |||
| 6674755800 | |||
| d919c75ea7 | |||
| 3a71c91eeb | |||
| 9d0e9aa8aa | |||
| d9c39a0206 | |||
| 0f73b6b07d | |||
| faffca0967 | |||
| 49c5c57be5 | |||
| 6053e69afc | |||
| 1e5e1bcdff | |||
| 64fbbb7958 | |||
| cfbb72051f | |||
| bf5897fc85 | |||
| ad6c744f2c | |||
| aad9bfbe8b | |||
| 42bd53ee7b | |||
| 86694ae4fe | |||
| 41225b3337 | |||
| 6bb5c2fb40 | |||
| bd1773e29e | |||
| 685320f3cf | |||
| b3982c8f72 | |||
| 002c0893f8 | |||
| d6081ba2d3 | |||
| 10cb3ae1df |
12
.env.example
@@ -51,9 +51,14 @@ PGID=1000
|
|||||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||||
|
|
||||||
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
|
# Admin API Key — /api/trade/* 등 민감 엔드포인트 보호.
|
||||||
|
# 운영 .env에는 반드시 값을 채워야 함. 빈 값이면 503 응답으로 거부됨 (CODE_REVIEW F2).
|
||||||
ADMIN_API_KEY=
|
ADMIN_API_KEY=
|
||||||
|
|
||||||
|
# 개발 모드: 위 ADMIN_API_KEY 비워둔 채로 trade/admin 엔드포인트 호출 허용.
|
||||||
|
# 운영 환경에서는 절대 true로 두지 말 것. 기본 false (보호 활성).
|
||||||
|
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||||
|
|
||||||
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
|
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
@@ -119,5 +124,6 @@ PACK_DATA_PATH=./data/packs
|
|||||||
PACK_BASE_DIR=/app/data/packs
|
PACK_BASE_DIR=/app/data/packs
|
||||||
|
|
||||||
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
|
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
|
||||||
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정. 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
|
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정.
|
||||||
PACK_HOST_DIR=/volume1/docker/webpage/media/packs
|
# 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
|
||||||
|
PACK_HOST_DIR=/docker/webpage/media/packs
|
||||||
|
|||||||
8
.gitignore
vendored
@@ -66,3 +66,11 @@ temp/
|
|||||||
|
|
||||||
# Git worktrees
|
# Git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
################################
|
||||||
|
# Local working files
|
||||||
|
################################
|
||||||
|
# Superpowers 스킬 캐시·세션 메타
|
||||||
|
.superpowers/
|
||||||
|
# 임시 코드 리뷰 노트 (작업 끝나면 폐기 또는 docs/로 이동)
|
||||||
|
CODE_REVIEW.md
|
||||||
|
|||||||
12
CLAUDE.md
@@ -467,6 +467,7 @@ docker compose up -d
|
|||||||
- `ANTHROPIC_MODEL_HAIKU` / `ANTHROPIC_MODEL_SONNET`: 모델명 오버라이드
|
- `ANTHROPIC_MODEL_HAIKU` / `ANTHROPIC_MODEL_SONNET`: 모델명 오버라이드
|
||||||
- `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`)
|
- `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`)
|
||||||
- `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`)
|
- `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`)
|
||||||
|
- `INSTA_DEFAULT_THEME`: 카드 렌더에 사용할 theme 디렉토리명 (기본 `default`). `templates/<theme>/card.html.j2`가 없으면 자동으로 default 폴백
|
||||||
- `NEWS_PER_CATEGORY` / `KEYWORDS_PER_CATEGORY`: 수집·추출 limit 튜닝
|
- `NEWS_PER_CATEGORY` / `KEYWORDS_PER_CATEGORY`: 수집·추출 limit 튜닝
|
||||||
|
|
||||||
**카테고리 시드 키워드**
|
**카테고리 시드 키워드**
|
||||||
@@ -482,6 +483,17 @@ docker compose up -d
|
|||||||
- 09:30 매일 — `_run_insta_schedule` (insta_pipeline) → 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시
|
- 09:30 매일 — `_run_insta_schedule` (insta_pipeline) → 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시
|
||||||
- `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송
|
- `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송
|
||||||
|
|
||||||
|
**디자인 import (사용자 디자인 PNG → Claude Vision → Jinja HTML 자동 생성)**
|
||||||
|
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
|
||||||
|
- CLI: `docker exec insta-lab python -m app.design_importer <theme>`
|
||||||
|
- 파일명 자동 매핑: `cover`/`start`/`intro` → page 1, `cta`/`outro`/`finish`/`end` → page 10, 나머지 알파벳 순 → page 2~9
|
||||||
|
- 매핑 override: `pages/_order.json`에 `{filename: page_no}` 명시 (10장 + page 1~10 완전 매핑일 때만 적용)
|
||||||
|
- Vision prompt에 placeholder 마스킹 요구 포함 (2-layer: 마스킹 박스 + 동적 텍스트 layer)
|
||||||
|
- 기존 HTML 자동 백업 (`card.html.j2.bak.YYYYMMDD-HHMMSS`)
|
||||||
|
- Jinja 문법 깨진 응답은 `card.html.j2.error.txt`로 보존 + ValueError
|
||||||
|
- 활성화: NAS `.env`에 `INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab`
|
||||||
|
- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준)
|
||||||
|
|
||||||
**insta-lab API 목록**
|
**insta-lab API 목록**
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|||||||
126
README.md
@@ -1,7 +1,7 @@
|
|||||||
# web-backend
|
# web-backend
|
||||||
|
|
||||||
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||||
로또 분석, 주식 포트폴리오, AI 음악 생성, 블로그 마케팅, 부동산 청약, AI 에이전트 오피스, 여행 앨범을 하나의 Docker Compose 스택으로 운영한다.
|
로또 분석, 주식 포트폴리오, AI 음악 생성, 인스타 카드 피드, 부동산 청약, AI 에이전트 오피스, 여행 앨범, 개인 서비스(포트폴리오·블로그·투두), NAS 자료 다운로드 자동화를 하나의 Docker Compose 스택으로 운영한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,33 +9,37 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
│ lotto-frontend (Nginx:8080) │
|
│ frontend (Nginx:8080) │
|
||||||
│ ├── 정적 SPA 서빙 (React + Vite) │
|
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||||
│ └── API 리버스 프록시 │
|
│ └── API 리버스 프록시 │
|
||||||
│ ├── /api/ → lotto-backend:8000 (로또·블로그·투두)│
|
│ ├── /api/ → lotto:8000 (로또) │
|
||||||
│ ├── /api/stock/, /trade/ → stock:8000 │
|
│ ├── /api/stock/, /trade/ → stock:8000 │
|
||||||
│ ├── /api/portfolio → stock:8000 │
|
│ ├── /api/portfolio → stock:8000 │
|
||||||
│ ├── /api/music/ → music-lab:8000 │
|
│ ├── /api/music/ → music-lab:8000 │
|
||||||
│ ├── /api/blog-marketing/ → blog-lab:8000 │
|
│ ├── /api/insta/ → insta-lab:8000 │
|
||||||
│ ├── /api/realestate/ → realestate-lab:8000 │
|
│ ├── /api/realestate/ → realestate-lab:8000 │
|
||||||
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
||||||
│ ├── /api/travel/ → travel-proxy:8000 │
|
│ ├── /api/profile/, /todos, /blog/ → personal:8000 │
|
||||||
│ ├── /media/music/… (nginx 직접 서빙, 생성 오디오) │
|
│ ├── /api/packs/ → packs-lab:8000 (HMAC + 5GB upload) │
|
||||||
|
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||||
|
│ ├── /media/music/, /media/videos/ (nginx 직접 서빙, 미디어) │
|
||||||
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
|
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
|
||||||
│ └── /webhook → deployer:9000 │
|
│ └── /webhook → deployer:9000 │
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
| 컨테이너 | 포트 | 역할 |
|
| 컨테이너 | 포트 | 역할 |
|
||||||
|---------|------|------|
|
|---------|------|------|
|
||||||
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API |
|
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||||
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||||
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
|
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) + YouTube 수익화 |
|
||||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
|
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드, Playwright) |
|
||||||
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
|
| `realestate-lab` | 18800 | 청약 공고 자동 수집·5티어 매칭·신규 매칭 push |
|
||||||
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
|
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
|
||||||
|
| `personal` | 18850 | 개인 서비스 — 포트폴리오·블로그·투두 통합 |
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 청크 업로드) |
|
||||||
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
|
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
|
||||||
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
| `frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||||
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -44,12 +48,14 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
|
|
||||||
```
|
```
|
||||||
web-backend/
|
web-backend/
|
||||||
├── backend/ # lotto-backend (로또·블로그·투두)
|
├── lotto/ # 로또 추천·통계·시뮬레이션
|
||||||
├── stock/ # 주식·포트폴리오
|
├── stock/ # 주식·포트폴리오·KIS 연동
|
||||||
├── music-lab/ # AI 음악 생성
|
├── music-lab/ # AI 음악 생성 + YouTube 수익화
|
||||||
├── blog-lab/ # 블로그 마케팅 파이프라인
|
├── insta-lab/ # 인스타 카드 피드 자동 생성 (Playwright)
|
||||||
├── realestate-lab/ # 청약 자동 수집·매칭
|
├── realestate-lab/ # 청약 자동 수집·5티어 매칭
|
||||||
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
||||||
|
├── personal/ # 포트폴리오·블로그·투두 통합
|
||||||
|
├── packs-lab/ # NAS 자료 다운로드 자동화 (HMAC + Supabase)
|
||||||
├── travel-proxy/ # 여행 사진 + 썸네일
|
├── travel-proxy/ # 여행 사진 + 썸네일
|
||||||
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
||||||
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
||||||
@@ -74,12 +80,14 @@ curl http://localhost:18500/health
|
|||||||
| 서비스 | 로컬 URL |
|
| 서비스 | 로컬 URL |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
| Frontend + API | http://localhost:8080 |
|
| Frontend + API | http://localhost:8080 |
|
||||||
| lotto-backend | http://localhost:18000 |
|
| lotto | http://localhost:18000 |
|
||||||
| stock | http://localhost:18500 |
|
| stock | http://localhost:18500 |
|
||||||
| music-lab | http://localhost:18600 |
|
| music-lab | http://localhost:18600 |
|
||||||
| blog-lab | http://localhost:18700 |
|
| insta-lab | http://localhost:18700 |
|
||||||
| realestate-lab | http://localhost:18800 |
|
| realestate-lab | http://localhost:18800 |
|
||||||
|
| personal | http://localhost:18850 |
|
||||||
| agent-office | http://localhost:18900 |
|
| agent-office | http://localhost:18900 |
|
||||||
|
| packs-lab | http://localhost:18950 |
|
||||||
| travel-proxy | http://localhost:19000 |
|
| travel-proxy | http://localhost:19000 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -123,20 +131,23 @@ curl http://localhost:18500/health
|
|||||||
- **라이브러리**: 생성 파일은 `/app/data/music/`에 저장되고 Nginx가 `/media/music/`으로 직접 서빙
|
- **라이브러리**: 생성 파일은 `/app/data/music/`에 저장되고 Nginx가 `/media/music/`으로 직접 서빙
|
||||||
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
|
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
|
||||||
|
|
||||||
### 4. blog-lab (`/api/blog-marketing/`)
|
### 4. insta-lab (`/api/insta/`)
|
||||||
|
|
||||||
블로그 마케팅 수익화 4단계 파이프라인 (`draft → marketed → reviewed → published`).
|
인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드.
|
||||||
|
|
||||||
```
|
```
|
||||||
리서치(Naver Search + 상위 블로그 본문 크롤링)
|
NAVER 뉴스 + YouTube 인기 (외부 트렌드)
|
||||||
→ 작가(AI 초안 생성)
|
→ 카테고리별 빈도 + Claude Haiku 정제 → 트렌딩 키워드
|
||||||
→ 마케터(전환율 강화 + 브랜드 링크 삽입)
|
→ 사용자가 키워드 선택
|
||||||
→ 평가자(6기준×10점, 42/60 통과 시 published)
|
→ Claude Sonnet으로 10페이지 카피 추론 (커버 1 + 본문 8 + CTA 1)
|
||||||
|
→ Jinja2 + Playwright 1080×1350 PNG 10장 렌더
|
||||||
|
→ 텔레그램 미디어 그룹 + 추천 캡션·해시태그
|
||||||
```
|
```
|
||||||
|
|
||||||
- **AI 엔진**: Claude API (`claude-sonnet-4-20250514`)
|
- **AI 엔진**: Claude Sonnet (카피) + Claude Haiku (키워드 분류)
|
||||||
- **키워드 분석**: 네이버 검색(블로그+쇼핑) API + 경쟁도/기회 점수
|
- **데이터 소스**: NAVER 뉴스 검색 + YouTube Data API v3 mostPopular(KR)
|
||||||
- **수익 추적**: 포스트별 월간 클릭/구매/수익 기록
|
- **카테고리 가중치**: 사용자가 economy/psychology/celebrity 등 카테고리별 가중치 설정 → 자동 추출 비율에 반영
|
||||||
|
- **카드 디자인**: `insta-lab/app/templates/default/card.html.j2` — 사용자가 자유 수정 (Tailwind 등)
|
||||||
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
|
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
|
||||||
|
|
||||||
### 5. realestate-lab (`/api/realestate/`)
|
### 5. realestate-lab (`/api/realestate/`)
|
||||||
@@ -152,7 +163,7 @@ curl http://localhost:18500/health
|
|||||||
|
|
||||||
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
||||||
|
|
||||||
- **아키텍처**: stock / music-lab / blog-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
- **아키텍처**: stock / music-lab / insta-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||||
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
|
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
|
||||||
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
|
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
|
||||||
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||||
@@ -165,22 +176,28 @@ AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에
|
|||||||
|---------|--------|-----|----------|
|
|---------|--------|-----|----------|
|
||||||
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
||||||
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
||||||
| ✍️ **블로그 마케터** (`blog`) | 10:00 매일 | ✅ 발행 | 트렌드 키워드 1개 선택 → 리서치→작가→마케터→평가 자동 실행 → 점수·본문을 텔레그램 승인 요청 → 승인 시 `published` 전환, 거절 시 재생성 |
|
| 🎴 **인스타 큐레이터** (`insta`) | 09:00 / 09:30 매일 | — | 09:00 외부 트렌드(NAVER + YouTube) 수집 → 09:30 가중치 기반 키워드 추출 → 텔레그램 후보 5개씩 카테고리당 인라인 버튼 푸시 → 사용자 선택 시 카드 10장 미디어 그룹 |
|
||||||
| 🏢 **청약 애널리스트** (`realestate`) | 09:15 매일 | — | realestate-lab 수집 트리거 → 신규 매칭 상위 5건 + 대시보드 요약을 텔레그램 리포트 (읽음 처리 자동) |
|
| 🏢 **청약 애널리스트** (`realestate`) | realestate-lab push trigger | — | realestate-lab이 신규 매칭 발견 시 push → 인라인 [북마크] 버튼 포함 텔레그램 알림 |
|
||||||
|
| 🎬 **YouTube 리서처** (`youtube`) | 09:00 매일 | — | 한국 YouTube 트렌딩 + Google Trends + Billboard → music-lab market_trends push |
|
||||||
|
|
||||||
#### 에이전트별 명령
|
#### 에이전트별 명령
|
||||||
|
|
||||||
**Stock** — `fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
|
**Stock** — `fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
|
||||||
**Music** — `compose` (승인 필요), `credits`
|
**Music** — `compose` (승인 필요), `credits`
|
||||||
**Blog** — `research {keyword}`, `add_trend_keyword`, `list_trend_keywords`
|
**Insta** — `extract`, `render <keyword_id>`, `collect_trends`
|
||||||
**Realestate** — `fetch_matches`, `dashboard`
|
**Realestate** — `fetch_matches`, `dashboard`
|
||||||
|
**YouTube** — `research {countries: [...]}`
|
||||||
|
|
||||||
#### 스케줄러 잡
|
#### 스케줄러 잡
|
||||||
|
|
||||||
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
||||||
- 07:30 — Stock: 뉴스 요약
|
- 07:30 — Stock: 뉴스 요약
|
||||||
- 09:15 — Realestate: 매칭 리포트
|
- 08:00 평일 — Stock: AI 뉴스 sentiment 분석
|
||||||
- 10:00 — Blog: 자동 파이프라인 (리서치→생성→리뷰→승인 대기)
|
- 09:00 — YouTube: 한국 트렌딩 수집
|
||||||
|
- 09:00 — Insta: 외부 트렌드 수집 (NAVER 인기 + YouTube mostPopular)
|
||||||
|
- 09:30 — Insta: 키워드 추출 (가중치 적용) + 텔레그램 후보 푸시
|
||||||
|
- 15:40 평일 — Stock: 총 자산 스냅샷
|
||||||
|
- 16:30 평일 — Stock: 스크리너 실행
|
||||||
- 60초 interval — 유휴 에이전트 휴식 체크
|
- 60초 interval — 유휴 에이전트 휴식 체크
|
||||||
|
|
||||||
### 7. travel-proxy (`/api/travel/`)
|
### 7. travel-proxy (`/api/travel/`)
|
||||||
@@ -265,13 +282,15 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
|||||||
|
|
||||||
| DB | 소유 서비스 | 주요 테이블 |
|
| DB | 소유 서비스 | 주요 테이블 |
|
||||||
|----|------------|-----------|
|
|----|------------|-----------|
|
||||||
| `lotto.db` | lotto-backend | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings, todos, blog_posts |
|
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
|
||||||
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
||||||
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls) |
|
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls), video_projects, revenue_records, market_trends, trend_reports |
|
||||||
| `blog_marketing.db` | blog-lab | keyword_analyses, blog_posts, brand_links, commissions, generation_tasks, prompt_templates |
|
| `insta.db` | insta-lab | news_articles, trending_keywords (source 컬럼), card_slates, card_assets, generation_tasks, prompt_templates, account_preferences |
|
||||||
| `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log |
|
| `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log |
|
||||||
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages |
|
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages |
|
||||||
|
| `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts |
|
||||||
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
||||||
|
| `pack_files` (외부 Supabase) | packs-lab | filename, host_path, mime, byte_size, sha256, deleted_at |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -292,33 +311,50 @@ PGID=1000
|
|||||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||||
WEBHOOK_SECRET=your_secret_here
|
WEBHOOK_SECRET=your_secret_here
|
||||||
|
|
||||||
# LLM (stock, blog-lab, agent-office 공통)
|
# LLM (stock, insta-lab, agent-office 공통)
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
LLM_PROVIDER=claude # claude | ollama
|
LLM_PROVIDER=claude # claude | ollama
|
||||||
OLLAMA_URL=http://192.168.45.59:11435
|
OLLAMA_URL=http://192.168.45.59:11435
|
||||||
OLLAMA_MODEL=qwen3:14b
|
OLLAMA_MODEL=qwen3:14b
|
||||||
|
|
||||||
|
# stock admin protection (CODE_REVIEW F2)
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||||
|
|
||||||
# music-lab
|
# music-lab
|
||||||
SUNO_API_KEY=
|
SUNO_API_KEY=
|
||||||
MUSIC_AI_SERVER_URL=
|
MUSIC_AI_SERVER_URL=
|
||||||
MUSIC_MEDIA_BASE=/media/music
|
MUSIC_MEDIA_BASE=/media/music
|
||||||
|
|
||||||
# blog-lab
|
# insta-lab + agent-office (NAVER 검색 + YouTube Data API 공유)
|
||||||
NAVER_CLIENT_ID=
|
NAVER_CLIENT_ID=
|
||||||
NAVER_CLIENT_SECRET=
|
NAVER_CLIENT_SECRET=
|
||||||
|
YOUTUBE_DATA_API_KEY=
|
||||||
|
|
||||||
# realestate-lab
|
# realestate-lab
|
||||||
DATA_GO_KR_API_KEY=
|
DATA_GO_KR_API_KEY=
|
||||||
|
|
||||||
|
# packs-lab (DSM + Supabase)
|
||||||
|
DSM_HOST=
|
||||||
|
DSM_USER=
|
||||||
|
DSM_PASS=
|
||||||
|
BACKEND_HMAC_SECRET=
|
||||||
|
SUPABASE_URL=
|
||||||
|
SUPABASE_SERVICE_KEY=
|
||||||
|
PACK_HOST_DIR=/docker/webpage/media/packs # shared folder 시점 (CLAUDE.md F5)
|
||||||
|
|
||||||
# agent-office
|
# agent-office
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_CHAT_ID=
|
TELEGRAM_CHAT_ID=
|
||||||
TELEGRAM_WEBHOOK_URL=
|
TELEGRAM_WEBHOOK_URL=
|
||||||
STOCK_URL=http://stock:8000
|
STOCK_URL=http://stock:8000
|
||||||
MUSIC_LAB_URL=http://music-lab:8000
|
MUSIC_LAB_URL=http://music-lab:8000
|
||||||
BLOG_LAB_URL=http://blog-lab:8000
|
INSTA_LAB_URL=http://insta-lab:8000
|
||||||
REALESTATE_LAB_URL=http://realestate-lab:8000
|
REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||||
|
|
||||||
|
# personal (포트폴리오 편집 인증)
|
||||||
|
PORTFOLIO_EDIT_PASSWORD=
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
34
STATUS.md
@@ -1,40 +1,42 @@
|
|||||||
# web-backend — 구현 현황 & 로드맵
|
# web-backend — 구현 현황 & 로드맵
|
||||||
|
|
||||||
> 최종 갱신: 2026-05-07
|
> 최종 갱신: 2026-05-17
|
||||||
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 서비스 구현 현황
|
## 1. 서비스 구현 현황
|
||||||
|
|
||||||
### 1-1. 운영 중인 컨테이너 (10개)
|
### 1-1. 운영 중인 컨테이너 (11개)
|
||||||
|
|
||||||
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
||||||
|--------|------|------|-----------|
|
|--------|------|------|-----------|
|
||||||
| `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 |
|
| `lotto` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역·AI 큐레이터 |
|
||||||
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
|
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷·스크리너 |
|
||||||
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
||||||
| `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 |
|
| `insta-lab` | 18700 | ✅ | 인스타 카드 피드 자동 생성 (NAVER + YouTube 트렌드 → 10페이지 카드, Playwright) |
|
||||||
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 |
|
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 push |
|
||||||
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + YouTubeResearcher) |
|
| `personal` | 18850 | ✅ | 포트폴리오·블로그·투두 통합 (개인 서비스) |
|
||||||
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase) — 2026-05-05 |
|
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + InstaAgent + YouTubeResearcher) |
|
||||||
|
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase + 5GB chunked upload) |
|
||||||
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
|
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
|
||||||
| `nginx` | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit) |
|
| `frontend` (nginx) | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit, 인스타 라우팅 포함) |
|
||||||
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 |
|
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 (BUILDKIT timeout 600s, healthcheck via docker inspect) |
|
||||||
|
|
||||||
### 1-2. 최근 큰 작업 (2026-04 ~ 05)
|
### 1-2. 최근 큰 작업 (2026-05)
|
||||||
|
|
||||||
| 시기 | 영역 | 핵심 |
|
| 시기 | 영역 | 핵심 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| 2026-05-17 | 보안 / 정합성 | CODE_REVIEW F1 (packs-lab path traversal `startswith→relative_to`) + F2 (stock admin auth 503 거부) + F4 (portfolio total_buy 수량 곱산) |
|
||||||
|
| 2026-05-17 | insta-lab | Google Trends API 폐기 대응 → YouTube Data API v3로 source 교체. trend_collector 재작성 |
|
||||||
|
| 2026-05-16 | insta-lab | Trends 탭 추가 — 외부 트렌드 수집 (NAVER 인기 + YouTube) + 카테고리 가중치 (`account_preferences`) + 가중치 기반 키워드 추출 |
|
||||||
|
| 2026-05-15 | insta-lab | blog-lab 폐기 → insta-lab 신설. 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG → 텔레그램 푸시 → 수동 인스타 업로드 파이프라인 |
|
||||||
| 2026-05-05 | packs-lab | sign-link / upload / list / delete + admin mint-token + 5GB nginx body limit + Supabase DDL |
|
| 2026-05-05 | packs-lab | sign-link / upload / list / delete + admin mint-token + 5GB nginx body limit + Supabase DDL |
|
||||||
| 2026-05-01~06 | music-lab | YouTube 수익화 백엔드 (market_trends·trend_reports DB + 5개 API) + 다중 트랙 FFmpeg concat MP4 |
|
| 2026-05-01~06 | music-lab | YouTube 수익화 백엔드 (market_trends·trend_reports DB + 5개 API) + 다중 트랙 FFmpeg concat MP4 |
|
||||||
| 2026-04-28 | realestate-lab | targeting enhancement (5티어 매칭·5축 점수·알림 대상 카운트) |
|
| 2026-04-28 | realestate-lab | targeting enhancement (5티어 매칭·5축 점수·알림 대상 카운트, realestate-lab push → agent-office RealestateAgent) |
|
||||||
| 2026-04-27 | personal | personal 서비스 분리 마이그레이션 (블로그·투두·포트폴리오 인증) |
|
| 2026-04-27 | personal | personal 서비스 분리 마이그레이션 (블로그·투두·포트폴리오 인증) |
|
||||||
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
|
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
|
||||||
| 2026-04-24 | travel-proxy | 갤러리 리디자인 + 성능 개선 (썸네일/페이지네이션) |
|
| 2026-04-15 | lotto | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||||
| 2026-04-15 | lotto-backend | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
|
||||||
| 2026-04-08 | music-lab | Suno enhancement + MusicGen 통합 |
|
|
||||||
| 2026-04-06 | blog-lab | 마케팅 파이프라인 (research → generate → market → review) |
|
|
||||||
|
|
||||||
### 1-3. 인프라 / DX
|
### 1-3. 인프라 / DX
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ class InstaAgent(BaseAgent):
|
|||||||
requires_approval=False)
|
requires_approval=False)
|
||||||
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
|
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
|
||||||
try:
|
try:
|
||||||
|
prefs = await service_proxy.insta_get_preferences()
|
||||||
|
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
|
||||||
await self._run_collect_and_extract()
|
await self._run_collect_and_extract()
|
||||||
kws = await service_proxy.insta_list_keywords(used=False)
|
kws = await service_proxy.insta_list_keywords(used=False)
|
||||||
if auto_select:
|
if auto_select:
|
||||||
@@ -147,6 +149,12 @@ class InstaAgent(BaseAgent):
|
|||||||
return {"ok": False, "message": "keyword_id 필수"}
|
return {"ok": False, "message": "keyword_id 필수"}
|
||||||
await self._render_and_push(kid)
|
await self._render_and_push(kid)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
if command == "collect_trends":
|
||||||
|
await messaging.send_raw("🌐 외부 트렌드 수집 시작")
|
||||||
|
created = await service_proxy.insta_collect_trends()
|
||||||
|
st = await self._wait_task(created["task_id"], step="trends_collect", timeout_sec=300)
|
||||||
|
await messaging.send_raw(f"✅ 트렌드 수집 완료: {st.get('message', '')}")
|
||||||
|
return {"ok": True, "result": st}
|
||||||
return {"ok": False, "message": f"Unknown command: {command}"}
|
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||||
|
|
||||||
async def on_callback(self, action: str, params: dict) -> dict:
|
async def on_callback(self, action: str, params: dict) -> dict:
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ async def _run_insta_schedule():
|
|||||||
if agent:
|
if agent:
|
||||||
await agent.on_schedule()
|
await agent.on_schedule()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_insta_trends_collect():
|
||||||
|
agent = AGENT_REGISTRY.get("insta")
|
||||||
|
if agent:
|
||||||
|
await agent.on_command("collect_trends", {})
|
||||||
|
|
||||||
async def _run_lotto_schedule():
|
async def _run_lotto_schedule():
|
||||||
agent = AGENT_REGISTRY.get("lotto")
|
agent = AGENT_REGISTRY.get("lotto")
|
||||||
if agent:
|
if agent:
|
||||||
@@ -68,6 +74,7 @@ def init_scheduler():
|
|||||||
id="stock_ai_news_sentiment",
|
id="stock_ai_news_sentiment",
|
||||||
)
|
)
|
||||||
scheduler.add_job(_run_insta_schedule, "cron", hour=9, minute=30, id="insta_pipeline")
|
scheduler.add_job(_run_insta_schedule, "cron", hour=9, minute=30, id="insta_pipeline")
|
||||||
|
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends_collect")
|
||||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0, id="lotto_curate")
|
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0, id="lotto_curate")
|
||||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0, id="youtube_research")
|
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0, id="youtube_research")
|
||||||
scheduler.add_job(_send_youtube_weekly_report, "cron", day_of_week="mon", hour=8, minute=0, id="youtube_weekly_report")
|
scheduler.add_job(_send_youtube_weekly_report, "cron", day_of_week="mon", hour=8, minute=0, id="youtube_weekly_report")
|
||||||
|
|||||||
@@ -167,6 +167,41 @@ async def insta_get_asset_bytes(slate_id: int, page: int) -> bytes:
|
|||||||
return resp.content
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_collect_trends(categories: Optional[list] = None) -> Dict[str, Any]:
|
||||||
|
payload = {"categories": categories} if categories else {}
|
||||||
|
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/trends/collect", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_list_trends(source: Optional[str] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
days: int = 1) -> List[Dict[str, Any]]:
|
||||||
|
params: Dict[str, Any] = {"days": days}
|
||||||
|
if source:
|
||||||
|
params["source"] = source
|
||||||
|
if category:
|
||||||
|
params["category"] = category
|
||||||
|
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/trends", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_get_preferences() -> Dict[str, float]:
|
||||||
|
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/preferences")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return {p["category"]: p["weight"] for p in resp.json().get("categories", [])}
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_put_preferences(weights: Dict[str, float]) -> Dict[str, Any]:
|
||||||
|
resp = await _client.put(
|
||||||
|
f"{INSTA_LAB_URL}/api/insta/preferences",
|
||||||
|
json={"categories": weights},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
# --- realestate-lab ---
|
# --- realestate-lab ---
|
||||||
|
|
||||||
async def realestate_collect() -> Dict[str, Any]:
|
async def realestate_collect() -> Dict[str, Any]:
|
||||||
|
|||||||
73
agent-office/tests/test_insta_agent_trends.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.agents.insta import InstaAgent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _init_db():
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
os.remove(_TMP)
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_command_collect_trends_dispatches(monkeypatch):
|
||||||
|
agent = InstaAgent()
|
||||||
|
fake_collect = AsyncMock(return_value={"task_id": "tcollect"})
|
||||||
|
fake_status = AsyncMock(return_value={"status": "succeeded", "result_id": 8,
|
||||||
|
"message": "naver:5, google:3"})
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect_trends", fake_collect)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
|
||||||
|
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||||
|
|
||||||
|
result = await agent.on_command("collect_trends", {})
|
||||||
|
assert result["ok"] is True
|
||||||
|
fake_collect.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_schedule_loads_preferences(monkeypatch):
|
||||||
|
"""on_schedule이 preferences를 가져오는지 확인."""
|
||||||
|
agent = InstaAgent()
|
||||||
|
|
||||||
|
fake_collect = AsyncMock(return_value={"task_id": "t1"})
|
||||||
|
fake_extract = AsyncMock(return_value={"task_id": "t2"})
|
||||||
|
fake_status = AsyncMock(side_effect=[
|
||||||
|
{"status": "succeeded", "result_id": 0},
|
||||||
|
{"status": "succeeded", "result_id": 0},
|
||||||
|
])
|
||||||
|
fake_keywords = AsyncMock(return_value=[
|
||||||
|
{"id": 1, "keyword": "K", "category": "economy", "score": 0.9},
|
||||||
|
])
|
||||||
|
fake_prefs = AsyncMock(return_value={"economy": 0.6, "psychology": 0.4})
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect", fake_collect)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_extract", fake_extract)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords", fake_keywords)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_preferences", fake_prefs)
|
||||||
|
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||||
|
|
||||||
|
agent.state = "idle"
|
||||||
|
await agent.on_schedule()
|
||||||
|
|
||||||
|
fake_prefs.assert_awaited()
|
||||||
@@ -100,8 +100,10 @@ services:
|
|||||||
- ANTHROPIC_MODEL_SONNET=${ANTHROPIC_MODEL_SONNET:-claude-sonnet-4-6}
|
- ANTHROPIC_MODEL_SONNET=${ANTHROPIC_MODEL_SONNET:-claude-sonnet-4-6}
|
||||||
- NAVER_CLIENT_ID=${NAVER_CLIENT_ID:-}
|
- NAVER_CLIENT_ID=${NAVER_CLIENT_ID:-}
|
||||||
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET:-}
|
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET:-}
|
||||||
|
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
|
||||||
- INSTA_DATA_PATH=/app/data
|
- INSTA_DATA_PATH=/app/data
|
||||||
- CARD_TEMPLATE_DIR=/app/app/templates
|
- CARD_TEMPLATE_DIR=/app/app/templates
|
||||||
|
- INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
volumes:
|
volumes:
|
||||||
- ${RUNTIME_PATH}/data/insta:/app/data
|
- ${RUNTIME_PATH}/data/insta:/app/data
|
||||||
|
|||||||
1785
docs/superpowers/plans/2026-05-16-insta-trends-implementation.md
Normal file
@@ -4,6 +4,10 @@
|
|||||||
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||||
연관 문서: `2026-05-15-insta-agent-design.md` (insta-lab 기본 설계)
|
연관 문서: `2026-05-15-insta-agent-design.md` (insta-lab 기본 설계)
|
||||||
|
|
||||||
|
## ⚠️ 변경 이력
|
||||||
|
|
||||||
|
- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 항목은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint 두 가지(`trendingsearches/daily/rss?geo=KR`, `/trends/api/dailytrends?...`)가 모두 404로 폐기되어 운영 호출이 빈 결과로 끝나는 문제 확인 → YouTube Data API v3 `videos.list?chart=mostPopular®ionCode=KR`로 source 대체. 이후 spec 본문을 읽을 때는 `google_trends` → `youtube_trending`, "Google Trends" → "YouTube 인기"로 치환 해석. 사유와 source 교체 시 동시 갱신 체크리스트: `feedback_external_data_sources.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 목적·배경
|
## 1. 목적·배경
|
||||||
@@ -50,7 +54,7 @@ insta-lab 운영 첫 사이클(2026-05-16 머지·배포 완료)에서 다음
|
|||||||
- 라이브러리: `pytrends` (PyPI, MIT)
|
- 라이브러리: `pytrends` (PyPI, MIT)
|
||||||
- `TrendReq(hl='ko-KR', tz=540).trending_searches(pn='south_korea')` 호출 → 일일 트렌딩 키워드 리스트
|
- `TrendReq(hl='ko-KR', tz=540).trending_searches(pn='south_korea')` 호출 → 일일 트렌딩 키워드 리스트
|
||||||
- 각 키워드에 대해 Claude Haiku 1회 호출로 카테고리 분류 (`economy` / `psychology` / `celebrity` / 사용자 추가 카테고리 / `uncategorized`)
|
- 각 키워드에 대해 Claude Haiku 1회 호출로 카테고리 분류 (`economy` / `psychology` / `celebrity` / 사용자 추가 카테고리 / `uncategorized`)
|
||||||
- LLM 분류 비용 절감을 위해 분류 결과를 1일 캐시 (같은 키워드 재호출 시 cache hit)
|
- LLM 분류 비용 절감을 위해 분류 결과를 1일 캐시 — `trend_collector` 모듈 레벨 `_category_cache: dict[str, tuple[str, float]]` (keyword → (category, expires_ts)), 컨테이너 lifetime 동안 유효. 같은 키워드 재요청 시 cache hit. 캐시는 영속화하지 않음 (재시작 시 첫 호출은 LLM 재분류)
|
||||||
- `trending_keywords` 테이블에 source='google_trends', score=traffic 정규화값
|
- `trending_keywords` 테이블에 source='google_trends', score=traffic 정규화값
|
||||||
|
|
||||||
### 3-3. 통합 저장
|
### 3-3. 통합 저장
|
||||||
@@ -121,7 +125,7 @@ def extract_with_weights(weights: dict[str, float], total_limit: int) -> list[Ke
|
|||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| POST | `/api/insta/trends/collect` | 두 source 모두 수집 (BackgroundTask) → `{task_id}` |
|
| POST | `/api/insta/trends/collect` | 두 source 모두 수집 (BackgroundTask) → `{task_id}` |
|
||||||
| GET | `/api/insta/trends` | 트렌드 조회. query: `source` (`naver_popular`/`google_trends`/`all`), `category`, `days` (default 1) |
|
| GET | `/api/insta/trends` | 트렌드 조회. query: `source` (`naver_popular`/`google_trends`/`all`), `category`, `days` (default 1, 의미: `suggested_at >= now() - days*24h`). 정렬 `suggested_at DESC, score DESC` |
|
||||||
| GET | `/api/insta/preferences` | 가중치 조회 → `{categories: [{category, weight, updated_at}]}` |
|
| GET | `/api/insta/preferences` | 가중치 조회 → `{categories: [{category, weight, updated_at}]}` |
|
||||||
| PUT | `/api/insta/preferences` | body `{categories: {economy: 0.6, ...}}` → upsert |
|
| PUT | `/api/insta/preferences` | body `{categories: {economy: 0.6, ...}}` → upsert |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# insta-lab Design Importer — Claude Vision으로 이미지 디자인 → Jinja HTML 자동 생성
|
||||||
|
|
||||||
|
작성일: 2026-05-17
|
||||||
|
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||||
|
연관 문서: `2026-05-15-insta-agent-design.md`, `2026-05-16-insta-trends-design.md`, `feedback_external_data_sources.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목적·배경
|
||||||
|
|
||||||
|
insta-lab의 카드 렌더는 현재 `templates/default/card.html.j2` 한 골격만 사용 (단순 그라데이션 + Noto Sans KR). 사용자가 직접 디자인한 10장 카드 이미지(`templates/minimal/pages/insta_card_*.png`)를 이미 NAS에 배포한 상태인데, 이 이미지들이 카드 렌더에 반영되지 않음.
|
||||||
|
|
||||||
|
이 spec은 사용자가 만든 디자인 이미지를 **카드 렌더 파이프라인에 통합**하는 메커니즘을 정의한다. 핵심은 Claude Vision으로 10장 PNG를 분석해 페이지별 텍스트 영역·색·폰트·레이아웃을 도출하고, 이를 그대로 모방한 단일 Jinja2 HTML 파일을 자동 생성하는 것이다. 생성된 HTML은 동적 카피(headline, body, cta)를 사용자 디자인 위에 layer로 얹어 일관된 시각 + 동적 텍스트를 동시에 확보한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 스코프
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- 신규 백엔드 모듈 `insta-lab/app/design_importer.py` — 10장 PNG → Claude Sonnet Vision → `card.html.j2` 생성
|
||||||
|
- CLI 진입점 `python -m app.design_importer <theme_name>` (운영자가 한 번씩 실행)
|
||||||
|
- 환경변수 `INSTA_DEFAULT_THEME` 신규 (default="default") — 모든 슬레이트가 이 theme 사용
|
||||||
|
- `card_renderer.render_slate`에 theme 전달 (기존 `template` 인자 활용, 호출자만 변경)
|
||||||
|
- pytest: Vision 호출 mock + 출력 HTML 파싱 검증
|
||||||
|
|
||||||
|
### 제외 (후속)
|
||||||
|
|
||||||
|
- API endpoint `POST /api/insta/templates/import` — UI에서 트리거 가능
|
||||||
|
- `card_slates.theme` 컬럼 — 슬레이트별 다른 theme 선택
|
||||||
|
- 다중 theme 비교/A·B 테스트 UI
|
||||||
|
- 자동 theme 추천 (트렌드 카테고리별 다른 theme)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 데이터·디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
insta-lab/app/templates/
|
||||||
|
├── default/ # 기존 — 폴백 / 초기 골격
|
||||||
|
│ ├── card.html.j2
|
||||||
|
│ └── .gitkeep
|
||||||
|
└── <theme_name>/ # 사용자 디자인 1세트 (반복 가능)
|
||||||
|
├── pages/ # 사용자가 git commit으로 업로드
|
||||||
|
│ ├── insta_card_start.png # 의미 있는 이름 권장 (Claude가 페이지 의도 파악에 활용)
|
||||||
|
│ ├── insta_card_keyword.png
|
||||||
|
│ ├── ... (총 10장)
|
||||||
|
│ └── README.md (선택, 디자인 의도 메모)
|
||||||
|
└── card.html.j2 # design_importer가 자동 생성
|
||||||
|
```
|
||||||
|
|
||||||
|
**파일명 컨벤션**:
|
||||||
|
- 페이지 번호 매핑은 사용자가 제공하지 않음. design_importer가 다음 순서로 자동 매핑:
|
||||||
|
1. 파일명에 `cover` > `start` > `intro` 키워드 포함 (우선순위 순서) → page 1 (커버). 여러 파일이 매치되면 가장 앞 키워드를 가진 파일만 선택, 나머지는 본문 풀로
|
||||||
|
2. 파일명에 `cta` > `outro` > `finish` > `end` 키워드 포함 (우선순위 순서) → page 10. 동일하게 첫 매치만 page 10, 나머지는 본문 풀로
|
||||||
|
3. 남은 8장은 알파벳 정렬 순으로 page 2~9 (본문)
|
||||||
|
- **현재 운영 케이스**: `insta_card_start.png`(start=1순위) → page 1, `insta_card_cta.png`(cta=1순위) → page 10, `insta_card_finish.png`는 finish=3순위인데 cta가 이미 page 10이므로 본문 풀로 떨어져 알파벳 순에 따라 page 2~9 어딘가 배치됨
|
||||||
|
- 사용자가 매핑을 override하려면 `pages/_order.json` 파일에 `{"insta_card_start.png": 1, "insta_card_finish.png": 10, ...}` 명시 가능 (충돌·의도 명시 시 강력 권장)
|
||||||
|
- 매핑이 의도와 어긋나면 importer 실행 결과 dict의 `page_mapping` 필드로 확인 후 `_order.json` 추가하고 재실행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 핵심 모듈 `design_importer.py`
|
||||||
|
|
||||||
|
### 4-1. Public API
|
||||||
|
|
||||||
|
```python
|
||||||
|
def import_design_theme(theme_name: str) -> dict:
|
||||||
|
"""templates/<theme>/pages/*.png 10장 → Claude Sonnet Vision → card.html.j2 생성.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"theme_name": str,
|
||||||
|
"html_path": str,
|
||||||
|
"page_mapping": {filename: page_no, ...},
|
||||||
|
"analysis_summary": str, # Claude가 도출한 디자인 분석 짧은 요약
|
||||||
|
"tokens_used": int,
|
||||||
|
}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: pages/ 폴더에 PNG 10장 미만이거나 매핑 실패
|
||||||
|
anthropic.APIError: Vision 호출 실패 (retry 1회 후)
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4-2. 처리 흐름
|
||||||
|
|
||||||
|
1. `templates/<theme>/pages/` 폴더 스캔 → PNG 10장 검증 (10장 정확히)
|
||||||
|
2. 파일명 → 페이지 매핑 결정 (3장 규칙 + 선택적 `_order.json` override)
|
||||||
|
3. 각 PNG base64 인코딩
|
||||||
|
4. Claude Sonnet(`claude-sonnet-4-6`) Vision 호출 1회:
|
||||||
|
- 시스템 프롬프트: 디자이너 역할 + 출력 형식 명세
|
||||||
|
- 사용자 메시지: 10장 이미지 + 페이지 매핑 정보 + 변수 명세 (`page_no`, `headline`, `body`, `cta`)
|
||||||
|
- 출력 요청: 단일 Jinja2 HTML 파일 (page_no 분기 + 텍스트 영역 절대 위치 CSS + `background-image: url('pages/{{filename}}')`)
|
||||||
|
5. 응답 HTML 파싱 + Jinja Environment로 sanity render 1회 (분기·문법 검증)
|
||||||
|
6. `templates/<theme>/card.html.j2`에 저장
|
||||||
|
7. dict 반환
|
||||||
|
|
||||||
|
### 4-3. Vision 프롬프트 스킴 (placeholder 텍스트 마스킹 포함)
|
||||||
|
|
||||||
|
**중요 제약**: 사용자 PNG에는 **placeholder 텍스트가 이미 박혀있다**. 동적 카피(headline, body, cta)로 교체해야 하며 원본 placeholder 텍스트는 보이면 안 된다. 따라서 단순히 텍스트 layer를 얹는 것만으로는 부족하고, 원본 텍스트가 있던 영역을 그 영역의 **배경색으로 덮은 후** 그 위에 새 텍스트를 그려야 한다.
|
||||||
|
|
||||||
|
시스템 프롬프트 (요약):
|
||||||
|
```
|
||||||
|
너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다.
|
||||||
|
입력: 10장의 카드 디자인 이미지 (각 1080×1350, placeholder 텍스트 포함) + 페이지 번호 매핑.
|
||||||
|
출력: 단일 Jinja2 HTML 파일.
|
||||||
|
|
||||||
|
요구사항:
|
||||||
|
- 컨테이너 width 1080px, height 1350px
|
||||||
|
- background-image로 해당 페이지 PNG를 url('pages/{{filename}}')로 로드
|
||||||
|
- 각 페이지에서 placeholder 텍스트가 있는 영역을 식별하고, 다음 두 layer를 그 위에 그린다:
|
||||||
|
(a) 마스킹 박스: position: absolute로 텍스트 영역과 같은 좌표·크기.
|
||||||
|
background는 PNG의 그 영역 주변 픽셀 색 (보통 카드 배경색)에서 추출.
|
||||||
|
placeholder가 완전히 가려지도록 padding 8px 정도 여유.
|
||||||
|
(b) 동적 텍스트 layer: 마스킹 박스와 동일 좌표.
|
||||||
|
font-size·font-weight·color는 원본 placeholder 폰트 스타일을 그대로 모방.
|
||||||
|
`{{ headline }}`, `{{ body }}`, `{{ cta }}` (page_no=10에서만) Jinja 변수 사용.
|
||||||
|
- 페이지 종류별 영역 추정:
|
||||||
|
· page 1 (cover): 메인 헤드라인 1개 영역. 보통 화면 상단 1/3 또는 중앙
|
||||||
|
· page 2~9 (body): 헤드라인 1개 + 본문 1개 영역 (보통 헤드라인 상단, 본문 그 아래)
|
||||||
|
· page 10 (cta): 헤드라인 1개 + 본문 1개 + CTA 강조 텍스트 1개 영역
|
||||||
|
- page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조
|
||||||
|
- 폰트는 Noto Sans KR (Google Fonts CDN), letter-spacing -0.02em
|
||||||
|
- 텍스트 영역은 word-wrap: break-word + overflow: hidden으로 길이 초과 시도 마스킹 박스 밖으로 새지 않게
|
||||||
|
- 출력은 <!DOCTYPE html>로 시작하는 완전한 HTML 본문만 (```html 코드펜스·설명 텍스트 금지)
|
||||||
|
```
|
||||||
|
|
||||||
|
사용자 메시지에 각 이미지 + filename + page_no 매핑 포함.
|
||||||
|
|
||||||
|
**시각 품질 보장 절차** (importer 운영 후 사용자 검증):
|
||||||
|
1. 첫 import 후 1개 슬레이트 생성해서 PNG 10장 육안 확인
|
||||||
|
2. placeholder 텍스트가 비치거나 마스킹 박스가 어색하면 — `card.html.j2`를 직접 수정해서 영역 좌표·색 fine-tune (백업 자동 보존)
|
||||||
|
3. 새 디자인을 import할 일 있을 때까지는 수동 수정본 그대로 사용
|
||||||
|
|
||||||
|
### 4-4. 캐시 / 재실행 정책
|
||||||
|
|
||||||
|
- 이미 `card.html.j2`가 존재하면 덮어쓰기 (사용자 명시적 재import 의도)
|
||||||
|
- 백업: 기존 HTML이 있으면 `card.html.j2.bak.YYYYMMDD-HHMMSS`로 rename 후 새 파일 작성
|
||||||
|
- 분석 결과 캐시 X (재실행할 때마다 최신 결과)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. CLI 진입점
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 컨테이너 내부에서 실행
|
||||||
|
docker exec insta-lab python -m app.design_importer <theme_name>
|
||||||
|
|
||||||
|
# 결과 stdout (예시)
|
||||||
|
{
|
||||||
|
"theme_name": "minimal",
|
||||||
|
"html_path": "/app/app/templates/minimal/card.html.j2",
|
||||||
|
"page_mapping": {
|
||||||
|
"insta_card_start.png": 1,
|
||||||
|
"insta_card_keyword.png": 2,
|
||||||
|
...
|
||||||
|
"insta_card_cta.png": 10
|
||||||
|
},
|
||||||
|
"analysis_summary": "미니멀 카드 — 흰 배경 + 검정 헤드라인 + 회색 본문...",
|
||||||
|
"tokens_used": 15234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`__main__` 가드: argparse로 `theme_name` 위치 인자 + `--force` (기존 HTML 백업 없이 덮어쓰기) 옵션. 실패 시 exit 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 카드 렌더 통합
|
||||||
|
|
||||||
|
### 6-1. 환경변수 추가 (`config.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6-2. `main.py:_bg_create_slate` 호출 변경
|
||||||
|
|
||||||
|
기존:
|
||||||
|
```python
|
||||||
|
await card_renderer.render_slate(sid)
|
||||||
|
```
|
||||||
|
|
||||||
|
신규:
|
||||||
|
```python
|
||||||
|
template_path = f"{INSTA_DEFAULT_THEME}/card.html.j2"
|
||||||
|
await card_renderer.render_slate(sid, template=template_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
`card_renderer.render_slate`는 이미 `template` 인자를 받으며 default 값이 `"default/card.html.j2"`. 변경 없음.
|
||||||
|
|
||||||
|
### 6-3. `card_renderer` 폴백 가드
|
||||||
|
|
||||||
|
`render_slate` 시작부에 template 파일 존재 확인 추가:
|
||||||
|
```python
|
||||||
|
template_full = Path(_resolve_template_dir()) / template
|
||||||
|
if not template_full.exists():
|
||||||
|
logger.warning("Template %s 없음, default로 폴백", template)
|
||||||
|
template = "default/card.html.j2"
|
||||||
|
```
|
||||||
|
|
||||||
|
→ env에 `INSTA_DEFAULT_THEME=minimal` 설정했는데 `minimal/card.html.j2`가 아직 import 안 됐으면 자동 default 폴백.
|
||||||
|
|
||||||
|
### 6-4. 운영 활성화 절차
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 이미지 commit + push (이미 완료 — minimal/pages/ 10장)
|
||||||
|
# 2. NAS 머지 후 design_importer 실행
|
||||||
|
docker exec insta-lab python -m app.design_importer minimal
|
||||||
|
|
||||||
|
# 3. NAS .env에 추가
|
||||||
|
echo "INSTA_DEFAULT_THEME=minimal" >> /volume1/docker/webpage/.env
|
||||||
|
|
||||||
|
# 4. 컨테이너 재시작 (env 재로드)
|
||||||
|
docker compose restart insta-lab
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 에러 처리
|
||||||
|
|
||||||
|
| 상황 | 처리 |
|
||||||
|
|------|------|
|
||||||
|
| `pages/` 폴더 없음 또는 PNG 10장 미만 | ValueError + 어떤 파일이 빠졌는지 명시. 모든 이미지가 1080×1350인지도 검증 (Pillow로 size 체크) |
|
||||||
|
| Vision 호출 실패 (network, rate limit) | retry 1회 (5초 대기), 그래도 실패 시 anthropic.APIError 전파 |
|
||||||
|
| Vision 응답이 HTML이 아님 / Jinja 문법 깨짐 | Jinja Environment로 sanity render 시도 → 실패 시 raw 응답을 `card.html.j2.error.txt`에 저장 + ValueError 전파 (운영자가 수동 수정 가능) |
|
||||||
|
| Vision 응답이 max_tokens(16K) 초과 → 잘림 | 응답 끝이 닫힌 `</html>` 없으면 잘렸다고 판단, max_tokens 24K로 retry 1회 |
|
||||||
|
| 이미지 base64 인코딩 실패 (파일 깨짐) | 어느 파일이 문제인지 로그 + ValueError |
|
||||||
|
| `_order.json` 형식 깨짐 | log warning + 자동 매핑 규칙으로 폴백 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 테스트
|
||||||
|
|
||||||
|
### `insta-lab/tests/test_design_importer.py` (~6 케이스)
|
||||||
|
|
||||||
|
1. `test_auto_page_mapping_with_cover_and_cta`: 의미 이름 파일 10개 → cover→1, cta→10, 나머지 알파벳 순
|
||||||
|
2. `test_explicit_order_json_overrides`: `_order.json` 있으면 그것 우선
|
||||||
|
3. `test_validates_exactly_ten_pngs`: 9장 또는 11장이면 ValueError
|
||||||
|
4. `test_validates_image_dimensions`: 1080×1350 아닌 이미지 있으면 ValueError + 어떤 파일인지
|
||||||
|
5. `test_import_generates_html_via_mocked_claude`: Anthropic Vision mock, 응답 HTML이 Jinja 렌더 가능한 형식인지 검증
|
||||||
|
6. `test_import_falls_back_on_jinja_parse_failure`: mock이 깨진 HTML 반환 시 ValueError + `.error.txt` 저장
|
||||||
|
|
||||||
|
### `insta-lab/tests/test_card_renderer.py` (기존, 보강 1개)
|
||||||
|
|
||||||
|
7. `test_render_falls_back_to_default_when_theme_html_missing`: `template="ghost/card.html.j2"` 지정 시 파일 없어도 default로 폴백 + 정상 PNG 생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 운영 영향
|
||||||
|
|
||||||
|
| 항목 | 영향 |
|
||||||
|
|------|------|
|
||||||
|
| Anthropic 토큰 비용 | +1회당 ~15K 토큰 (이미지 10장 × ~1K + 프롬프트 + HTML 출력). Claude Sonnet 단가 기준 ~$0.05/import. 자주 실행 X |
|
||||||
|
| 빌드 시간 | 영향 없음 (코드 변경만, 의존성 추가 없음) |
|
||||||
|
| 카드 렌더 시간 | 영향 없음 (Playwright는 background-image까지 wait_until="networkidle"로 처리) |
|
||||||
|
| 디스크 | 사용자 디자인 PNG 12MB (이미 push됨) + 자동 생성 HTML ~10KB |
|
||||||
|
| 운영 중 카드 품질 | env `INSTA_DEFAULT_THEME=minimal` 설정 후 다음 슬레이트부터 사용자 디자인 적용. 기존 슬레이트는 default 그대로 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 마이그레이션 절차
|
||||||
|
|
||||||
|
배포 후 사용자가 운영 NAS에서 수동 실행:
|
||||||
|
|
||||||
|
1. PR 머지 → webhook으로 `design_importer.py` 코드 배포 + minimal/ 디렉토리는 이미 배포됨
|
||||||
|
2. SSH NAS:
|
||||||
|
```bash
|
||||||
|
docker exec insta-lab python -m app.design_importer minimal
|
||||||
|
```
|
||||||
|
3. 결과 JSON에서 `html_path`와 `page_mapping` 확인. 매핑이 의도와 다르면 `pages/_order.json`로 override 후 재실행
|
||||||
|
4. `.env`에 `INSTA_DEFAULT_THEME=minimal` 추가
|
||||||
|
5. `docker compose restart insta-lab` (env 재로드)
|
||||||
|
6. 새 슬레이트 1개 만들어서 시각 검증 (Insta 페이지 Trends 탭 또는 수동 트리거)
|
||||||
|
|
||||||
|
생성된 `card.html.j2`가 마음에 안 들면:
|
||||||
|
- `pages/_order.json`으로 페이지 순서 조정 후 importer 재실행
|
||||||
|
- 또는 자동 생성 HTML을 사용자가 직접 수정 (importer 재실행 안 함)
|
||||||
|
- 백업본 `card.html.j2.bak.YYYYMMDD-HHMMSS`로 롤백 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 완료 정의
|
||||||
|
|
||||||
|
- [ ] `insta-lab/app/design_importer.py` 작성, CLI `python -m app.design_importer` 작동
|
||||||
|
- [ ] `_resolve_page_mapping` + 의미 이름 기반 자동 매핑 + `_order.json` override
|
||||||
|
- [ ] Vision 호출 mock 기반 pytest 6 케이스 PASS
|
||||||
|
- [ ] `card_renderer.render_slate`에 theme 폴백 가드 추가, 테스트 1 케이스 PASS
|
||||||
|
- [ ] `insta-lab/app/config.py`에 `INSTA_DEFAULT_THEME` 추가
|
||||||
|
- [ ] `insta-lab/app/main.py:_bg_create_slate`가 `INSTA_DEFAULT_THEME` 사용
|
||||||
|
- [ ] `docker-compose.yml` insta-lab 환경변수에 `INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}` 추가
|
||||||
|
- [ ] CLAUDE.md 9.x insta-lab 섹션에 design_importer + INSTA_DEFAULT_THEME 항목 추가
|
||||||
|
- [ ] 운영 NAS에서 `docker exec insta-lab python -m app.design_importer minimal` 실행 → `card.html.j2` 생성 확인
|
||||||
|
- [ ] `.env` 설정 + 새 슬레이트 1개 생성 → 시각적으로 minimal 디자인 반영 확인
|
||||||
@@ -16,7 +16,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
# --timeout 600 --retries 5: NAS 느린 네트워크/CPU에서 pip 다운로드 timeout 방지
|
||||||
|
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||||
RUN playwright install chromium
|
RUN playwright install chromium
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
@@ -67,6 +68,13 @@ async def render_slate(slate_id: int, template: str = "default/card.html.j2") ->
|
|||||||
if not slate:
|
if not slate:
|
||||||
raise ValueError(f"slate {slate_id} not found")
|
raise ValueError(f"slate {slate_id} not found")
|
||||||
env = _env()
|
env = _env()
|
||||||
|
|
||||||
|
# template 파일이 없으면 default로 폴백 (INSTA_DEFAULT_THEME가 import 안 된 theme이면 안전)
|
||||||
|
template_full = Path(_resolve_template_dir()) / template
|
||||||
|
if not template_full.exists():
|
||||||
|
logger.warning("Template '%s' 없음 → 'default/card.html.j2'로 폴백", template)
|
||||||
|
template = "default/card.html.j2"
|
||||||
|
|
||||||
tmpl = env.get_template(template)
|
tmpl = env.get_template(template)
|
||||||
pages = _build_pages(slate)
|
pages = _build_pages(slate)
|
||||||
out_dir = _slate_dir(slate_id)
|
out_dir = _slate_dir(slate_id)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
|
|
||||||
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
|
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
|
||||||
NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
|
NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
|
||||||
|
YOUTUBE_DATA_API_KEY = os.getenv("YOUTUBE_DATA_API_KEY", "")
|
||||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
ANTHROPIC_MODEL_HAIKU = os.getenv("ANTHROPIC_MODEL_HAIKU", "claude-haiku-4-5-20251001")
|
ANTHROPIC_MODEL_HAIKU = os.getenv("ANTHROPIC_MODEL_HAIKU", "claude-haiku-4-5-20251001")
|
||||||
ANTHROPIC_MODEL_SONNET = os.getenv("ANTHROPIC_MODEL_SONNET", "claude-sonnet-4-6")
|
ANTHROPIC_MODEL_SONNET = os.getenv("ANTHROPIC_MODEL_SONNET", "claude-sonnet-4-6")
|
||||||
@@ -10,6 +11,7 @@ INSTA_DATA_PATH = os.getenv("INSTA_DATA_PATH", "/app/data")
|
|||||||
DB_PATH = os.path.join(INSTA_DATA_PATH, "insta.db")
|
DB_PATH = os.path.join(INSTA_DATA_PATH, "insta.db")
|
||||||
CARDS_DIR = os.path.join(INSTA_DATA_PATH, "insta_cards")
|
CARDS_DIR = os.path.join(INSTA_DATA_PATH, "insta_cards")
|
||||||
CARD_TEMPLATE_DIR = os.getenv("CARD_TEMPLATE_DIR", "/app/app/templates")
|
CARD_TEMPLATE_DIR = os.getenv("CARD_TEMPLATE_DIR", "/app/app/templates")
|
||||||
|
INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")
|
||||||
|
|
||||||
CORS_ALLOW_ORIGINS = os.getenv(
|
CORS_ALLOW_ORIGINS = os.getenv(
|
||||||
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
||||||
|
|||||||
@@ -101,6 +101,29 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# source column for trending_keywords (idempotent ALTER)
|
||||||
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(trending_keywords)").fetchall()]
|
||||||
|
if "source" not in cols:
|
||||||
|
conn.execute("ALTER TABLE trending_keywords ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_tk_source ON trending_keywords(source, suggested_at DESC)")
|
||||||
|
|
||||||
|
# account_preferences — 카테고리 가중치
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS account_preferences (
|
||||||
|
category TEXT PRIMARY KEY,
|
||||||
|
weight REAL NOT NULL DEFAULT 1.0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# seed defaults if table empty
|
||||||
|
existing = conn.execute("SELECT COUNT(*) FROM account_preferences").fetchone()[0]
|
||||||
|
if existing == 0:
|
||||||
|
for cat in ("economy", "psychology", "celebrity"):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO account_preferences(category, weight) VALUES(?,?)",
|
||||||
|
(cat, 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── news_articles ────────────────────────────────────────────────
|
# ── news_articles ────────────────────────────────────────────────
|
||||||
def add_news_article(row: Dict[str, Any]) -> int:
|
def add_news_article(row: Dict[str, Any]) -> int:
|
||||||
@@ -132,8 +155,12 @@ def list_news_articles(category: Optional[str] = None, days: int = 1) -> List[Di
|
|||||||
def add_trending_keyword(row: Dict[str, Any]) -> int:
|
def add_trending_keyword(row: Dict[str, Any]) -> int:
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO trending_keywords(keyword, category, score, articles_count) VALUES(?,?,?,?)",
|
"INSERT INTO trending_keywords(keyword, category, score, articles_count, source) VALUES(?,?,?,?,?)",
|
||||||
(row["keyword"], row["category"], float(row.get("score", 0.0)), int(row.get("articles_count", 0))),
|
(
|
||||||
|
row["keyword"], row["category"],
|
||||||
|
float(row.get("score", 0.0)), int(row.get("articles_count", 0)),
|
||||||
|
row.get("source", "manual"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
|
|
||||||
@@ -276,3 +303,50 @@ def get_prompt_template(name: str) -> Optional[Dict[str, Any]]:
|
|||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM prompt_templates WHERE name=?", (name,)).fetchone()
|
row = conn.execute("SELECT * FROM prompt_templates WHERE name=?", (name,)).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── external trends ─────────────────────────────────────────────
|
||||||
|
def add_external_trend(row: Dict[str, Any]) -> int:
|
||||||
|
"""`source` 필수 — naver_popular | google_trends. trending_keywords에 인서트."""
|
||||||
|
if "source" not in row:
|
||||||
|
raise ValueError("add_external_trend requires 'source' field")
|
||||||
|
return add_trending_keyword(row)
|
||||||
|
|
||||||
|
|
||||||
|
def list_trends(source: Optional[str] = None, category: Optional[str] = None,
|
||||||
|
days: int = 1) -> List[Dict[str, Any]]:
|
||||||
|
sql = "SELECT * FROM trending_keywords WHERE suggested_at >= datetime('now', ?)"
|
||||||
|
params: List[Any] = [f"-{int(days)} days"]
|
||||||
|
if source and source != "all":
|
||||||
|
sql += " AND source=?"
|
||||||
|
params.append(source)
|
||||||
|
if category:
|
||||||
|
sql += " AND category=?"
|
||||||
|
params.append(category)
|
||||||
|
sql += " ORDER BY suggested_at DESC, score DESC"
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── account_preferences ─────────────────────────────────────────
|
||||||
|
def get_preferences() -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT category, weight, updated_at FROM account_preferences ORDER BY category ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_preferences(weights: Dict[str, float]) -> None:
|
||||||
|
"""전체 upsert. 기존에 있던 카테고리는 weight 갱신, 신규는 INSERT.
|
||||||
|
명시되지 않은 기존 카테고리는 그대로 둔다 (삭제 X). 삭제 필요 시 별도 API로."""
|
||||||
|
with _conn() as conn:
|
||||||
|
for cat, w in weights.items():
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO account_preferences(category, weight)
|
||||||
|
VALUES(?,?)
|
||||||
|
ON CONFLICT(category) DO UPDATE SET
|
||||||
|
weight=excluded.weight,
|
||||||
|
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
""", (cat, float(w)))
|
||||||
|
|||||||
296
insta-lab/app/design_importer.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성.
|
||||||
|
|
||||||
|
CLI (이 phase 이후 추가): python -m app.design_importer <theme_name>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
from anthropic import Anthropic
|
||||||
|
from jinja2 import BaseLoader, Environment, TemplateSyntaxError
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"_resolve_page_mapping",
|
||||||
|
"_validate_images",
|
||||||
|
"_call_vision",
|
||||||
|
"_validate_html_template",
|
||||||
|
"import_design_theme",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 페이지 1 (커버) 키워드 우선순위 — 먼저 매치된 키워드를 가진 첫 파일만 page 1
|
||||||
|
_COVER_KEYWORDS = ("cover", "start", "intro")
|
||||||
|
# 페이지 10 (CTA) 키워드 우선순위
|
||||||
|
_CTA_KEYWORDS = ("cta", "outro", "finish", "end")
|
||||||
|
|
||||||
|
# 인스타그램 카드 규격 (세로형 4:5 비율)
|
||||||
|
_EXPECTED_SIZE = (1080, 1350)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_page_mapping(pages_dir: Path) -> Dict[str, int]:
|
||||||
|
"""templates/<theme>/pages/ 안의 PNG 10장을 page 1~10에 매핑.
|
||||||
|
|
||||||
|
우선순위:
|
||||||
|
1. `_order.json` 있으면 그 매핑 그대로 사용 (검증 통과 시 반환)
|
||||||
|
2. 자동 매핑:
|
||||||
|
- _COVER_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 1
|
||||||
|
- _CTA_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 10
|
||||||
|
- 남은 8장은 알파벳 정렬 → page 2~9
|
||||||
|
"""
|
||||||
|
pages_dir = Path(pages_dir)
|
||||||
|
pngs = sorted([p.name for p in pages_dir.glob("*.png")])
|
||||||
|
if len(pngs) != 10:
|
||||||
|
raise ValueError(
|
||||||
|
f"{pages_dir}에 PNG 10장 필요, 발견 {len(pngs)}장: {pngs}"
|
||||||
|
)
|
||||||
|
|
||||||
|
order_path = pages_dir / "_order.json"
|
||||||
|
if order_path.exists():
|
||||||
|
try:
|
||||||
|
mapping = json.loads(order_path.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("_order.json 파싱 실패, 자동 매핑으로 폴백: %s", e)
|
||||||
|
else:
|
||||||
|
if set(mapping.keys()) == set(pngs) and set(mapping.values()) == set(range(1, 11)):
|
||||||
|
return {k: int(v) for k, v in mapping.items()}
|
||||||
|
logger.warning(
|
||||||
|
"_order.json 형식 오류 (파일 누락·page 중복), 자동 매핑으로 폴백"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_mapping(pngs)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_by_keywords(names: List[str], keywords: tuple) -> str | None:
|
||||||
|
"""names 중 keywords의 우선순위에 따라 첫 매치 파일명 반환 (없으면 None)."""
|
||||||
|
lower_names = [(n, n.lower()) for n in names]
|
||||||
|
for kw in keywords:
|
||||||
|
for orig, low in lower_names:
|
||||||
|
if kw in low:
|
||||||
|
return orig
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mapping(pngs: List[str]) -> Dict[str, int]:
|
||||||
|
"""자동 매핑 알고리즘 본체."""
|
||||||
|
mapping: Dict[str, int] = {}
|
||||||
|
remaining = list(pngs)
|
||||||
|
|
||||||
|
cover = _pick_by_keywords(remaining, _COVER_KEYWORDS)
|
||||||
|
if cover:
|
||||||
|
mapping[cover] = 1
|
||||||
|
remaining.remove(cover)
|
||||||
|
|
||||||
|
cta = _pick_by_keywords(remaining, _CTA_KEYWORDS)
|
||||||
|
if cta:
|
||||||
|
mapping[cta] = 10
|
||||||
|
remaining.remove(cta)
|
||||||
|
|
||||||
|
remaining_sorted = sorted(remaining)
|
||||||
|
free_pages = sorted(set(range(1, 11)) - set(mapping.values()))
|
||||||
|
for name, page in zip(remaining_sorted, free_pages):
|
||||||
|
mapping[name] = page
|
||||||
|
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_images(pages_dir: Path) -> None:
|
||||||
|
"""모든 PNG가 정확히 1080×1350인지 검증. 다르면 ValueError.
|
||||||
|
|
||||||
|
early-exit 하지 않고 전체 파일을 검사한 뒤 한 메시지에 모아 raise.
|
||||||
|
"""
|
||||||
|
pages_dir = Path(pages_dir)
|
||||||
|
bad = []
|
||||||
|
for png_path in sorted(pages_dir.glob("*.png")):
|
||||||
|
with Image.open(png_path) as img:
|
||||||
|
if img.size != _EXPECTED_SIZE:
|
||||||
|
bad.append((png_path.name, img.size))
|
||||||
|
if bad:
|
||||||
|
msg = "; ".join(f"{n}: {s[0]}x{s[1]}" for n, s in bad)
|
||||||
|
raise ValueError(
|
||||||
|
f"모든 카드 디자인은 1080x1350이어야 함. 잘못된 파일: {msg}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Vision 호출 + HTML 생성 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_VISION_SYSTEM_PROMPT = """너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다.
|
||||||
|
|
||||||
|
입력: 10장의 카드 디자인 이미지 (각 1080×1350, placeholder 텍스트가 박혀있음) + 파일명 → 페이지 번호 매핑.
|
||||||
|
출력: 단일 Jinja2 HTML 파일 본문 (코드펜스·설명 텍스트 금지).
|
||||||
|
|
||||||
|
핵심 제약 — placeholder 텍스트 마스킹:
|
||||||
|
PNG에는 디자인 placeholder 텍스트가 이미 그려져 있다. 동적 카피로 교체할 때
|
||||||
|
원본 텍스트가 비치면 안 된다. 각 텍스트 영역마다 두 layer를 그려라:
|
||||||
|
(a) 마스킹 박스: position: absolute로 placeholder 영역과 같은 좌표.
|
||||||
|
background는 그 영역 주변 픽셀 색 (카드 배경색)에서 추출. padding 8px 여유.
|
||||||
|
(b) 동적 텍스트 layer: 마스킹 박스와 동일 좌표.
|
||||||
|
font-size·font-weight·color는 원본 placeholder의 스타일을 모방.
|
||||||
|
{{ headline }} / {{ body }} / {{ cta }} Jinja 변수 사용.
|
||||||
|
|
||||||
|
페이지 종류별 영역 가이드:
|
||||||
|
- page 1 (cover): 메인 headline 1개 영역
|
||||||
|
- page 2~9 (body): headline 영역 + body 영역
|
||||||
|
- page 10 (cta): headline + body + cta 영역
|
||||||
|
|
||||||
|
요구사항:
|
||||||
|
- 컨테이너 width 1080px, height 1350px
|
||||||
|
- 각 페이지마다 `background-image: url('pages/{{filename}}')`로 사용자 PNG 로드
|
||||||
|
- page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조
|
||||||
|
- 폰트는 Noto Sans KR (Google Fonts CDN). letter-spacing -0.02em, line-height 1.3 기본
|
||||||
|
- 텍스트 영역은 word-wrap: break-word + overflow: hidden (동적 카피가 길어도 마스킹 박스 밖으로 안 새도록)
|
||||||
|
- HTML <head>에 <style>로 모든 CSS 인라인. <link> 외부 stylesheet 금지
|
||||||
|
- 출력은 <!DOCTYPE html>로 시작하는 완전한 HTML 문서
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _call_vision(images_with_pages: List[Tuple[str, int, bytes]],
|
||||||
|
theme_name: str) -> Dict[str, Any]:
|
||||||
|
"""Claude Sonnet Vision 호출. images_with_pages: [(filename, page_no, png_bytes), ...].
|
||||||
|
|
||||||
|
Returns: {"html": str, "tokens": int, "summary": str}
|
||||||
|
"""
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise RuntimeError("ANTHROPIC_API_KEY 미설정 — design_importer 사용 불가")
|
||||||
|
|
||||||
|
client = Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||||
|
content: List[Dict[str, Any]] = []
|
||||||
|
for filename, page_no, png_bytes in sorted(images_with_pages, key=lambda x: x[1]):
|
||||||
|
content.append({
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/png",
|
||||||
|
"data": base64.b64encode(png_bytes).decode("ascii"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
content.append({
|
||||||
|
"type": "text",
|
||||||
|
"text": f"위 이미지 = '{filename}' = page {page_no}",
|
||||||
|
})
|
||||||
|
content.append({
|
||||||
|
"type": "text",
|
||||||
|
"text": (
|
||||||
|
f"theme 이름: '{theme_name}'. 위 10장 디자인을 모방한 단일 Jinja2 HTML을 출력해라."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
msg = client.messages.create(
|
||||||
|
model=ANTHROPIC_MODEL_SONNET,
|
||||||
|
max_tokens=16000,
|
||||||
|
system=_VISION_SYSTEM_PROMPT,
|
||||||
|
messages=[{"role": "user", "content": content}],
|
||||||
|
)
|
||||||
|
raw = msg.content[0].text.strip()
|
||||||
|
# 코드펜스 자르기
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = re.sub(r"^```(?:html)?\s*|\s*```$", "", raw).strip()
|
||||||
|
summary = raw[:200].replace("\n", " ") # 첫 200자만 분석 요약으로
|
||||||
|
return {
|
||||||
|
"html": raw,
|
||||||
|
"tokens": msg.usage.input_tokens + msg.usage.output_tokens,
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_html_template(html: str) -> None:
|
||||||
|
"""Jinja2 Environment로 sanity render. 문법 오류면 TemplateSyntaxError 전파."""
|
||||||
|
env = Environment(loader=BaseLoader())
|
||||||
|
env.from_string(html) # 파싱만으로도 syntax error 검출
|
||||||
|
|
||||||
|
|
||||||
|
def import_design_theme(theme_dir: str) -> Dict[str, Any]:
|
||||||
|
"""templates/<theme>/pages/*.png 10장 → Vision → card.html.j2 생성.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_dir: theme 디렉토리 절대 경로 (예: /app/app/templates/minimal)
|
||||||
|
Returns:
|
||||||
|
{theme_name, html_path, page_mapping, analysis_summary, tokens_used}
|
||||||
|
"""
|
||||||
|
theme_path = Path(theme_dir)
|
||||||
|
theme_name = theme_path.name
|
||||||
|
pages_dir = theme_path / "pages"
|
||||||
|
|
||||||
|
# 1. 매핑 + 검증
|
||||||
|
mapping = _resolve_page_mapping(pages_dir)
|
||||||
|
_validate_images(pages_dir)
|
||||||
|
|
||||||
|
# 2. Vision 호출
|
||||||
|
images_with_pages = []
|
||||||
|
for filename, page_no in mapping.items():
|
||||||
|
png_bytes = (pages_dir / filename).read_bytes()
|
||||||
|
images_with_pages.append((filename, page_no, png_bytes))
|
||||||
|
|
||||||
|
vision_result = _call_vision(images_with_pages, theme_name)
|
||||||
|
html = vision_result["html"]
|
||||||
|
|
||||||
|
# 3. Jinja sanity
|
||||||
|
html_path = theme_path / "card.html.j2"
|
||||||
|
try:
|
||||||
|
_validate_html_template(html)
|
||||||
|
except TemplateSyntaxError as e:
|
||||||
|
error_path = theme_path / "card.html.j2.error.txt"
|
||||||
|
error_path.write_text(html, encoding="utf-8")
|
||||||
|
raise ValueError(
|
||||||
|
f"Vision 응답이 Jinja 문법 오류: {e}. 원본 HTML은 {error_path}에 저장됨"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 백업 + 저장
|
||||||
|
if html_path.exists():
|
||||||
|
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup_path = theme_path / f"card.html.j2.bak.{ts}"
|
||||||
|
html_path.rename(backup_path)
|
||||||
|
logger.info("기존 HTML 백업: %s", backup_path)
|
||||||
|
|
||||||
|
html_path.write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"theme_name": theme_name,
|
||||||
|
"html_path": str(html_path),
|
||||||
|
"page_mapping": mapping,
|
||||||
|
"analysis_summary": vision_result["summary"],
|
||||||
|
"tokens_used": vision_result["tokens"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI entrypoint ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main_cli():
|
||||||
|
"""CLI: python -m app.design_importer <theme_name> [--templates-dir PATH]"""
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="design_importer",
|
||||||
|
description="사용자 카드 디자인 PNG 10장을 Claude Vision으로 분석해 card.html.j2 생성",
|
||||||
|
)
|
||||||
|
parser.add_argument("theme_name", help="templates/<theme_name>/ 디렉토리명")
|
||||||
|
parser.add_argument(
|
||||||
|
"--templates-dir",
|
||||||
|
default="/app/app/templates",
|
||||||
|
help="templates 루트 디렉토리 (기본 컨테이너 내부 경로)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
theme_dir = Path(args.templates_dir) / args.theme_name
|
||||||
|
if not theme_dir.is_dir():
|
||||||
|
print(f"ERROR: theme 디렉토리 없음: {theme_dir}")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = import_design_theme(str(theme_dir))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: {e}")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main_cli()
|
||||||
@@ -81,3 +81,22 @@ def extract_for_category(category: str, limit: int = KEYWORDS_PER_CATEGORY) -> L
|
|||||||
})
|
})
|
||||||
saved.append({"id": kid, **kw, "category": category})
|
saved.append({"id": kid, **kw, "category": category})
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def extract_with_weights(weights: Dict[str, float], total_limit: int) -> List[Dict[str, Any]]:
|
||||||
|
"""카테고리 가중치 비율대로 키워드를 분배 추출."""
|
||||||
|
from .config import DEFAULT_CATEGORY_SEEDS
|
||||||
|
if not weights or sum(weights.values()) == 0:
|
||||||
|
cats = list(DEFAULT_CATEGORY_SEEDS.keys())
|
||||||
|
weights = {c: 1.0 for c in cats}
|
||||||
|
|
||||||
|
total_weight = sum(weights.values())
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for category, w in weights.items():
|
||||||
|
if w <= 0:
|
||||||
|
continue
|
||||||
|
per_cat = round(total_limit * w / total_weight)
|
||||||
|
if per_cat <= 0:
|
||||||
|
continue
|
||||||
|
out.extend(extract_for_category(category, limit=per_cat))
|
||||||
|
return out
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ from pydantic import BaseModel
|
|||||||
from .config import (
|
from .config import (
|
||||||
CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY,
|
CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY,
|
||||||
INSTA_DATA_PATH, DB_PATH, DEFAULT_CATEGORY_SEEDS, KEYWORDS_PER_CATEGORY,
|
INSTA_DATA_PATH, DB_PATH, DEFAULT_CATEGORY_SEEDS, KEYWORDS_PER_CATEGORY,
|
||||||
|
INSTA_DEFAULT_THEME,
|
||||||
)
|
)
|
||||||
from . import db, news_collector, keyword_extractor, card_writer, card_renderer
|
from . import db, news_collector, keyword_extractor, card_writer, card_renderer, trend_collector
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -99,11 +100,16 @@ class ExtractRequest(BaseModel):
|
|||||||
categories: Optional[list[str]] = None
|
categories: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
async def _bg_extract(task_id: str, categories: list[str]):
|
async def _bg_extract(task_id: str, categories: Optional[list[str]] = None):
|
||||||
try:
|
try:
|
||||||
db.update_task(task_id, "processing", 10, "추출 중")
|
db.update_task(task_id, "processing", 10, "추출 중")
|
||||||
for cat in categories:
|
prefs_rows = db.get_preferences()
|
||||||
keyword_extractor.extract_for_category(cat, limit=KEYWORDS_PER_CATEGORY)
|
weights = {p["category"]: p["weight"] for p in prefs_rows}
|
||||||
|
if categories:
|
||||||
|
# 사용자가 카테고리 명시한 경우만 그 서브셋으로 균등 가중치 (override)
|
||||||
|
weights = {c: 1.0 for c in categories}
|
||||||
|
total = KEYWORDS_PER_CATEGORY * max(1, len([w for w in weights.values() if w > 0]))
|
||||||
|
keyword_extractor.extract_with_weights(weights, total_limit=total)
|
||||||
db.update_task(task_id, "succeeded", 100, "완료", result_id=0)
|
db.update_task(task_id, "succeeded", 100, "완료", result_id=0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("extract failed")
|
logger.exception("extract failed")
|
||||||
@@ -119,7 +125,13 @@ def extract_keywords(req: ExtractRequest, bg: BackgroundTasks):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/insta/keywords")
|
@app.get("/api/insta/keywords")
|
||||||
def list_keywords(category: Optional[str] = None, used: Optional[bool] = None):
|
def list_keywords(
|
||||||
|
category: Optional[str] = None,
|
||||||
|
used: Optional[bool] = None,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
):
|
||||||
|
if source:
|
||||||
|
return {"items": db.list_trends(source=source, category=category, days=30)}
|
||||||
return {"items": db.list_trending_keywords(category=category, used=used)}
|
return {"items": db.list_trending_keywords(category=category, used=used)}
|
||||||
|
|
||||||
|
|
||||||
@@ -135,7 +147,7 @@ async def _bg_create_slate(task_id: str, keyword: str, category: str, keyword_id
|
|||||||
db.update_task(task_id, "processing", 30, "카피 생성 중")
|
db.update_task(task_id, "processing", 30, "카피 생성 중")
|
||||||
sid = card_writer.write_slate(keyword=keyword, category=category)
|
sid = card_writer.write_slate(keyword=keyword, category=category)
|
||||||
db.update_task(task_id, "processing", 70, "카드 렌더 중")
|
db.update_task(task_id, "processing", 70, "카드 렌더 중")
|
||||||
await card_renderer.render_slate(sid)
|
await card_renderer.render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
||||||
db.update_slate_status(sid, "rendered")
|
db.update_slate_status(sid, "rendered")
|
||||||
if keyword_id:
|
if keyword_id:
|
||||||
db.mark_keyword_used(keyword_id)
|
db.mark_keyword_used(keyword_id)
|
||||||
@@ -175,7 +187,7 @@ def get_slate(slate_id: int):
|
|||||||
async def _bg_render(task_id: str, slate_id: int):
|
async def _bg_render(task_id: str, slate_id: int):
|
||||||
try:
|
try:
|
||||||
db.update_task(task_id, "processing", 30, "재렌더 중")
|
db.update_task(task_id, "processing", 30, "재렌더 중")
|
||||||
await card_renderer.render_slate(slate_id)
|
await card_renderer.render_slate(slate_id, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
||||||
db.update_slate_status(slate_id, "rendered")
|
db.update_slate_status(slate_id, "rendered")
|
||||||
db.update_task(task_id, "succeeded", 100, "완료", result_id=slate_id)
|
db.update_task(task_id, "succeeded", 100, "완료", result_id=slate_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -243,3 +255,52 @@ def get_prompt(name: str):
|
|||||||
def upsert_prompt(name: str, body: TemplateBody):
|
def upsert_prompt(name: str, body: TemplateBody):
|
||||||
db.upsert_prompt_template(name, body.template, body.description)
|
db.upsert_prompt_template(name, body.template, body.description)
|
||||||
return db.get_prompt_template(name)
|
return db.get_prompt_template(name)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Trends ───────────────────────────────────────────────────────
|
||||||
|
class TrendsCollectRequest(BaseModel):
|
||||||
|
categories: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _bg_collect_trends(task_id: str, categories: list[str]):
|
||||||
|
try:
|
||||||
|
db.update_task(task_id, "processing", 10, "외부 트렌드 수집 중")
|
||||||
|
result = trend_collector.collect_all(categories)
|
||||||
|
msg = f"naver:{result['naver_popular']}, youtube:{result['youtube_trending']}"
|
||||||
|
db.update_task(task_id, "succeeded", 100, msg, result_id=sum(result.values()))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("trends collect failed")
|
||||||
|
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/insta/trends/collect")
|
||||||
|
def collect_trends(req: TrendsCollectRequest, bg: BackgroundTasks):
|
||||||
|
cats = req.categories or list(DEFAULT_CATEGORY_SEEDS.keys())
|
||||||
|
tid = db.create_task("trends_collect", {"categories": cats})
|
||||||
|
bg.add_task(_bg_collect_trends, tid, cats)
|
||||||
|
return {"task_id": tid, "categories": cats}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/insta/trends")
|
||||||
|
def list_trends_endpoint(
|
||||||
|
source: Optional[str] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
days: int = Query(1, ge=1, le=90),
|
||||||
|
):
|
||||||
|
return {"items": db.list_trends(source=source, category=category, days=days)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Preferences ──────────────────────────────────────────────────
|
||||||
|
class PreferencesBody(BaseModel):
|
||||||
|
categories: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/insta/preferences")
|
||||||
|
def get_preferences_endpoint():
|
||||||
|
return {"categories": db.get_preferences()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/insta/preferences")
|
||||||
|
def put_preferences_endpoint(body: PreferencesBody):
|
||||||
|
db.upsert_preferences(body.categories)
|
||||||
|
return {"categories": db.get_preferences()}
|
||||||
|
|||||||
BIN
insta-lab/app/templates/minimal/pages/insta_card_checklist.png
Normal file
|
After Width: | Height: | Size: 1010 KiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_cta.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_finish.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_highlight.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_keyword.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_memo.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_observation.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_oneline.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_start.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_study.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
250
insta-lab/app/trend_collector.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""외부 트렌드 수집 — NAVER 인기 + YouTube 인기 영상 + LLM 카테고리 분류.
|
||||||
|
|
||||||
|
NAVER: 카테고리별 시드 키워드로 인기 검색 → 빈도 상위 추출.
|
||||||
|
YouTube: Google Trends 비공식 endpoint(RSS / dailytrends JSON)가 모두 404 폐기되어
|
||||||
|
대체로 YouTube Data API v3 (`videos.list?chart=mostPopular®ionCode=KR`) 사용.
|
||||||
|
무료 일일 quota 10000, 한국 region 지원, 인기 영상 50개 제목에서 트렌드 추출.
|
||||||
|
LLM 분류 결과는 24h in-memory 캐시.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from anthropic import Anthropic
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
NAVER_CLIENT_ID, NAVER_CLIENT_SECRET, DEFAULT_CATEGORY_SEEDS,
|
||||||
|
ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU, YOUTUBE_DATA_API_KEY,
|
||||||
|
)
|
||||||
|
from . import db
|
||||||
|
from .news_collector import _clean
|
||||||
|
from .keyword_extractor import _count_nouns, _top_candidates
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NEWS_URL = "https://openapi.naver.com/v1/search/news.json"
|
||||||
|
_NAVER_HEADERS = {
|
||||||
|
"X-Naver-Client-Id": NAVER_CLIENT_ID,
|
||||||
|
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
|
||||||
|
}
|
||||||
|
|
||||||
|
YOUTUBE_TRENDING_URL = "https://www.googleapis.com/youtube/v3/videos"
|
||||||
|
# YouTube 제목 정제: 대괄호·이모지·과도한 길이 제거 후 카드 주제로 적합한 키워드 형태
|
||||||
|
_TITLE_BRACKET_RE = re.compile(r"[\[【「『\(][^\]】」』\)]{0,30}[\]】」』\)]")
|
||||||
|
_EMOJI_RE = re.compile(
|
||||||
|
r"["
|
||||||
|
r"\U0001F300-\U0001FAFF" # symbols & pictographs, etc.
|
||||||
|
r"\U00002600-\U000027BF" # misc symbols, dingbats
|
||||||
|
r"\U0001F1E6-\U0001F1FF" # regional indicator
|
||||||
|
r"]"
|
||||||
|
)
|
||||||
|
_TITLE_MAX_LEN = 60
|
||||||
|
|
||||||
|
_PLACEHOLDER_SEEDS = {"...", "…", "tbd", "todo", "placeholder", "example"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_seed(s: str) -> bool:
|
||||||
|
"""프롬프트 템플릿에 placeholder/빈 값이 들어가 NAVER에 400을 유발하는 일을 막는 가드."""
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
s = s.strip()
|
||||||
|
if len(s) < 2:
|
||||||
|
return False
|
||||||
|
if s.lower() in _PLACEHOLDER_SEEDS:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _seeds_for(category: str) -> List[str]:
|
||||||
|
"""category_seeds 프롬프트 템플릿이 있으면 사용, 없거나 모두 invalid면 config DEFAULT 폴백."""
|
||||||
|
pt = db.get_prompt_template("category_seeds")
|
||||||
|
if pt and pt.get("template"):
|
||||||
|
try:
|
||||||
|
data = json.loads(pt["template"])
|
||||||
|
if category in data:
|
||||||
|
filtered = [s for s in (data[category] or []) if _is_valid_seed(s)]
|
||||||
|
if filtered:
|
||||||
|
return filtered
|
||||||
|
logger.warning("category_seeds[%s]에 유효한 시드 없음 → DEFAULT 폴백", category)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("category_seeds JSON 파싱 실패 → DEFAULT 폴백: %s", e)
|
||||||
|
return list(DEFAULT_CATEGORY_SEEDS.get(category, []))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_naver_popular(category: str, per_seed: int = 30, top_n: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""카테고리 시드 키워드들로 NAVER news.json `sort=sim` 호출,
|
||||||
|
응답 기사 묶음에서 빈도어 추출 후 상위 N개 반환."""
|
||||||
|
seeds = _seeds_for(category)
|
||||||
|
if not seeds:
|
||||||
|
return []
|
||||||
|
blob_parts: List[str] = []
|
||||||
|
for seed in seeds:
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
NEWS_URL,
|
||||||
|
headers=_NAVER_HEADERS,
|
||||||
|
params={"query": seed, "display": per_seed, "sort": "sim"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
for item in resp.json().get("items", []):
|
||||||
|
blob_parts.append(_clean(item.get("title", "")))
|
||||||
|
blob_parts.append(_clean(item.get("description", "")))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("fetch_naver_popular seed=%s err=%s", seed, e)
|
||||||
|
continue
|
||||||
|
text = "\n".join(blob_parts)
|
||||||
|
counts = _count_nouns(text)
|
||||||
|
candidates = _top_candidates(counts, n=top_n)
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
max_count = candidates[0][1] or 1
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"keyword": k,
|
||||||
|
"category": category,
|
||||||
|
"source": "naver_popular",
|
||||||
|
"score": round(min(1.0, c / max_count), 4),
|
||||||
|
"articles_count": c,
|
||||||
|
}
|
||||||
|
for k, c in candidates
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def collect_naver_popular_for(categories: List[str]) -> int:
|
||||||
|
total = 0
|
||||||
|
for cat in categories:
|
||||||
|
trends = fetch_naver_popular(cat)
|
||||||
|
for t in trends:
|
||||||
|
db.add_external_trend(t)
|
||||||
|
total += 1
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
# ── LLM 분류 캐시 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_CACHE_TTL_SEC = 24 * 3600
|
||||||
|
_category_cache: Dict[str, tuple] = {} # keyword -> (category, expires_ts)
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_classify_one(keyword: str) -> str:
|
||||||
|
"""Claude Haiku 1회 호출로 단일 키워드 분류."""
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
return "uncategorized"
|
||||||
|
seeds_template = db.get_prompt_template("category_seeds")
|
||||||
|
if seeds_template and seeds_template.get("template"):
|
||||||
|
try:
|
||||||
|
allowed = sorted(json.loads(seeds_template["template"]).keys())
|
||||||
|
except Exception:
|
||||||
|
allowed = sorted(DEFAULT_CATEGORY_SEEDS.keys())
|
||||||
|
else:
|
||||||
|
allowed = sorted(DEFAULT_CATEGORY_SEEDS.keys())
|
||||||
|
allowed.append("uncategorized")
|
||||||
|
|
||||||
|
client = Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||||
|
msg = client.messages.create(
|
||||||
|
model=ANTHROPIC_MODEL_HAIKU,
|
||||||
|
max_tokens=20,
|
||||||
|
messages=[{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"다음 한국어 트렌딩 키워드를 카테고리 중 하나로 분류해라. "
|
||||||
|
f"카테고리: {allowed}. 키워드: '{keyword}'. "
|
||||||
|
f"카테고리명 한 단어만 출력. 다른 텍스트 금지."
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
raw = msg.content[0].text.strip().lower()
|
||||||
|
for cat in allowed:
|
||||||
|
if cat.lower() in raw:
|
||||||
|
return cat
|
||||||
|
return "uncategorized"
|
||||||
|
|
||||||
|
|
||||||
|
def classify_keyword(keyword: str) -> str:
|
||||||
|
now = time.time()
|
||||||
|
cached = _category_cache.get(keyword)
|
||||||
|
if cached and cached[1] > now:
|
||||||
|
return cached[0]
|
||||||
|
cat = _llm_classify_one(keyword)
|
||||||
|
_category_cache[keyword] = (cat, now + _CACHE_TTL_SEC)
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
# ── YouTube Trending ──────────────────────────────────────────────────────────
|
||||||
|
# YouTube Data API v3 videos.list?chart=mostPopular®ionCode=KR
|
||||||
|
# 한국 인기 영상 50개 제목에서 카드 주제로 적합한 키워드 추출.
|
||||||
|
|
||||||
|
def _clean_yt_title(title: str) -> str:
|
||||||
|
"""[공식]·【속보】·🔥 등 제거 후 60자 이내로 자른다."""
|
||||||
|
if not title:
|
||||||
|
return ""
|
||||||
|
cleaned = _TITLE_BRACKET_RE.sub("", title)
|
||||||
|
cleaned = _EMOJI_RE.sub("", cleaned)
|
||||||
|
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
return cleaned[:_TITLE_MAX_LEN]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_youtube_trending() -> List[Dict[str, Any]]:
|
||||||
|
"""YouTube Data API v3 mostPopular (한국, 50개). API 키 없거나 호출 실패 시 빈 리스트."""
|
||||||
|
if not YOUTUBE_DATA_API_KEY:
|
||||||
|
logger.info("YOUTUBE_DATA_API_KEY 미설정 — youtube_trending skip")
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
YOUTUBE_TRENDING_URL,
|
||||||
|
params={
|
||||||
|
"part": "snippet",
|
||||||
|
"chart": "mostPopular",
|
||||||
|
"regionCode": "KR",
|
||||||
|
"maxResults": 50,
|
||||||
|
"key": YOUTUBE_DATA_API_KEY,
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
videos = resp.json().get("items", []) or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("YouTube trending fetch failed: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
seen = set()
|
||||||
|
total = max(1, len(videos))
|
||||||
|
for idx, v in enumerate(videos):
|
||||||
|
title = (v.get("snippet") or {}).get("title", "")
|
||||||
|
kw = _clean_yt_title(title)
|
||||||
|
if not kw or kw in seen:
|
||||||
|
continue
|
||||||
|
seen.add(kw)
|
||||||
|
try:
|
||||||
|
cat = classify_keyword(kw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("classify_keyword(%s) 실패: %s", kw, e)
|
||||||
|
cat = "uncategorized"
|
||||||
|
rank_score = round(max(0.0, 1.0 - (idx / total)), 4)
|
||||||
|
items.append({
|
||||||
|
"keyword": kw,
|
||||||
|
"category": cat,
|
||||||
|
"source": "youtube_trending",
|
||||||
|
"score": rank_score,
|
||||||
|
"articles_count": 0,
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def collect_youtube_trending() -> int:
|
||||||
|
items = fetch_youtube_trending()
|
||||||
|
for it in items:
|
||||||
|
db.add_external_trend(it)
|
||||||
|
return len(items)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_all(categories: List[str]) -> Dict[str, int]:
|
||||||
|
naver_n = collect_naver_popular_for(categories)
|
||||||
|
yt_n = collect_youtube_trending()
|
||||||
|
return {"naver_popular": naver_n, "youtube_trending": yt_n}
|
||||||
@@ -46,3 +46,14 @@ async def test_render_slate_produces_ten_pngs(tmp_db_and_dirs):
|
|||||||
db_module.update_slate_status(sid, "rendered")
|
db_module.update_slate_status(sid, "rendered")
|
||||||
assets = db_module.list_card_assets(sid)
|
assets = db_module.list_card_assets(sid)
|
||||||
assert {a["page_index"] for a in assets} == set(range(1, 11))
|
assert {a["page_index"] for a in assets} == set(range(1, 11))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_render_falls_back_to_default_when_theme_html_missing(tmp_db_and_dirs):
|
||||||
|
"""존재하지 않는 theme HTML 지정 시 default/card.html.j2로 폴백, 정상 PNG 생성."""
|
||||||
|
sid = _seed_slate()
|
||||||
|
paths = await card_renderer.render_slate(sid, template="ghost_theme/card.html.j2")
|
||||||
|
assert len(paths) == 10
|
||||||
|
for p in paths:
|
||||||
|
assert os.path.exists(p)
|
||||||
|
assert os.path.getsize(p) > 1000
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def tmp_db(monkeypatch):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def test_init_db_creates_six_tables(tmp_db):
|
def test_init_db_creates_seven_tables(tmp_db):
|
||||||
with db_module._conn() as conn:
|
with db_module._conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||||
@@ -33,6 +33,7 @@ def test_init_db_creates_six_tables(tmp_db):
|
|||||||
assert names == sorted([
|
assert names == sorted([
|
||||||
"news_articles", "trending_keywords", "card_slates",
|
"news_articles", "trending_keywords", "card_slates",
|
||||||
"card_assets", "generation_tasks", "prompt_templates",
|
"card_assets", "generation_tasks", "prompt_templates",
|
||||||
|
"account_preferences",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
168
insta-lab/tests/test_design_importer.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""design_importer 회귀 테스트."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import design_importer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_theme(tmp_path):
|
||||||
|
"""templates/<theme>/pages/ 구조를 가진 임시 디렉토리."""
|
||||||
|
pages = tmp_path / "minimal" / "pages"
|
||||||
|
pages.mkdir(parents=True)
|
||||||
|
return tmp_path / "minimal"
|
||||||
|
|
||||||
|
|
||||||
|
def _touch(pages_dir: Path, names: list[str]):
|
||||||
|
for n in names:
|
||||||
|
(pages_dir / n).write_bytes(b"") # 매핑 테스트는 dimension 검증 안 함
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_page_mapping_with_cover_and_cta(tmp_theme):
|
||||||
|
"""cover 키워드 → 1, cta 키워드 → 10, 나머지는 알파벳 순 2~9."""
|
||||||
|
_touch(tmp_theme / "pages", [
|
||||||
|
"insta_card_start.png", # start → page 1 (cover priority)
|
||||||
|
"insta_card_keyword.png",
|
||||||
|
"insta_card_highlight.png",
|
||||||
|
"insta_card_observation.png",
|
||||||
|
"insta_card_memo.png",
|
||||||
|
"insta_card_oneline.png",
|
||||||
|
"insta_card_checklist.png",
|
||||||
|
"insta_card_study.png",
|
||||||
|
"insta_card_cta.png", # cta → page 10
|
||||||
|
"insta_card_finish.png", # finish은 cta가 이미 채워 본문 풀로
|
||||||
|
])
|
||||||
|
mapping = design_importer._resolve_page_mapping(tmp_theme / "pages")
|
||||||
|
assert mapping["insta_card_start.png"] == 1
|
||||||
|
assert mapping["insta_card_cta.png"] == 10
|
||||||
|
# 본문 풀 (남은 8장)은 알파벳 정렬: checklist, finish, highlight, keyword, memo, observation, oneline, study
|
||||||
|
body_pages = {p: n for n, p in mapping.items() if 2 <= p <= 9}
|
||||||
|
assert body_pages[2] == "insta_card_checklist.png"
|
||||||
|
assert body_pages[3] == "insta_card_finish.png"
|
||||||
|
assert body_pages[9] == "insta_card_study.png"
|
||||||
|
assert set(mapping.values()) == set(range(1, 11))
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_order_json_overrides_auto_mapping(tmp_theme):
|
||||||
|
"""_order.json이 있으면 자동 매핑보다 우선."""
|
||||||
|
pages = tmp_theme / "pages"
|
||||||
|
_touch(pages, [
|
||||||
|
"insta_card_start.png",
|
||||||
|
"insta_card_cta.png",
|
||||||
|
"insta_card_finish.png",
|
||||||
|
] + [f"insta_card_body{i}.png" for i in range(1, 8)])
|
||||||
|
(pages / "_order.json").write_text(json.dumps({
|
||||||
|
"insta_card_start.png": 1,
|
||||||
|
"insta_card_finish.png": 10, # cta 대신 finish를 page 10으로
|
||||||
|
"insta_card_cta.png": 5, # cta를 본문 한가운데로 강제
|
||||||
|
"insta_card_body1.png": 2,
|
||||||
|
"insta_card_body2.png": 3,
|
||||||
|
"insta_card_body3.png": 4,
|
||||||
|
"insta_card_body4.png": 6,
|
||||||
|
"insta_card_body5.png": 7,
|
||||||
|
"insta_card_body6.png": 8,
|
||||||
|
"insta_card_body7.png": 9,
|
||||||
|
}), encoding="utf-8")
|
||||||
|
mapping = design_importer._resolve_page_mapping(pages)
|
||||||
|
assert mapping["insta_card_finish.png"] == 10
|
||||||
|
assert mapping["insta_card_cta.png"] == 5
|
||||||
|
assert mapping["insta_card_start.png"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_validates_exactly_ten_pngs(tmp_theme):
|
||||||
|
"""PNG가 정확히 10장이 아니면 ValueError."""
|
||||||
|
_touch(tmp_theme / "pages", [f"x{i}.png" for i in range(5)]) # 5장
|
||||||
|
with pytest.raises(ValueError, match="10"):
|
||||||
|
design_importer._resolve_page_mapping(tmp_theme / "pages")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_png(path: Path, size: tuple[int, int]) -> None:
|
||||||
|
"""size 픽셀의 단색 PNG를 생성."""
|
||||||
|
from PIL import Image
|
||||||
|
Image.new("RGB", size, color=(200, 200, 200)).save(path, format="PNG")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_images_accepts_1080x1350(tmp_theme):
|
||||||
|
pages = tmp_theme / "pages"
|
||||||
|
for i in range(10):
|
||||||
|
_make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350))
|
||||||
|
# 예외 없이 통과해야 함
|
||||||
|
design_importer._validate_images(pages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_images_rejects_wrong_dimensions(tmp_theme):
|
||||||
|
pages = tmp_theme / "pages"
|
||||||
|
for i in range(10):
|
||||||
|
size = (800, 800) if i == 5 else (1080, 1350)
|
||||||
|
_make_png(pages / f"insta_card_{i:02d}.png", size)
|
||||||
|
with pytest.raises(ValueError, match="1080x1350"):
|
||||||
|
design_importer._validate_images(pages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_design_theme_writes_html_via_mocked_vision(tmp_theme, monkeypatch):
|
||||||
|
"""Vision mock이 정상 HTML 반환 시 card.html.j2 파일이 저장되고 결과 dict 반환."""
|
||||||
|
pages = tmp_theme / "pages"
|
||||||
|
names = [
|
||||||
|
"insta_card_start.png",
|
||||||
|
"insta_card_cta.png",
|
||||||
|
] + [f"insta_card_body{i}.png" for i in range(8)]
|
||||||
|
for n in names:
|
||||||
|
_make_png(pages / n, (1080, 1350))
|
||||||
|
|
||||||
|
fake_html = """<!DOCTYPE html><html><body>
|
||||||
|
{% if page_no == 1 %}<div class="cover">{{ headline }}</div>{% endif %}
|
||||||
|
{% if page_no >= 2 and page_no <= 9 %}<div class="body">{{ headline }}<p>{{ body }}</p></div>{% endif %}
|
||||||
|
{% if page_no == 10 %}<div class="cta">{{ headline }}<p>{{ cta }}</p></div>{% endif %}
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
def fake_vision_call(images_with_pages, theme_name):
|
||||||
|
return {"html": fake_html, "tokens": 12345, "summary": "test summary"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(design_importer, "_call_vision", fake_vision_call)
|
||||||
|
|
||||||
|
result = design_importer.import_design_theme(str(tmp_theme))
|
||||||
|
assert result["theme_name"] == "minimal"
|
||||||
|
assert "card.html.j2" in result["html_path"]
|
||||||
|
assert (tmp_theme / "card.html.j2").exists()
|
||||||
|
assert (tmp_theme / "card.html.j2").read_text(encoding="utf-8") == fake_html
|
||||||
|
assert "insta_card_start.png" in result["page_mapping"]
|
||||||
|
assert result["tokens_used"] == 12345
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_design_theme_raises_on_jinja_parse_failure(tmp_theme, monkeypatch):
|
||||||
|
"""Vision이 깨진 Jinja 반환 시 ValueError + .error.txt 보존."""
|
||||||
|
pages = tmp_theme / "pages"
|
||||||
|
for i in range(10):
|
||||||
|
_make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350))
|
||||||
|
|
||||||
|
broken_html = "<div>{% if page_no == 1 unclosed"
|
||||||
|
|
||||||
|
monkeypatch.setattr(design_importer, "_call_vision",
|
||||||
|
lambda imgs, name: {"html": broken_html, "tokens": 100, "summary": ""})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Jinja"):
|
||||||
|
design_importer.import_design_theme(str(tmp_theme))
|
||||||
|
assert (tmp_theme / "card.html.j2.error.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_design_theme_backs_up_existing_html(tmp_theme, monkeypatch):
|
||||||
|
"""기존 card.html.j2가 있으면 .bak.YYYYMMDD-HHMMSS로 백업 후 새로 작성."""
|
||||||
|
pages = tmp_theme / "pages"
|
||||||
|
for i in range(10):
|
||||||
|
_make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350))
|
||||||
|
(tmp_theme / "card.html.j2").write_text("OLD HTML", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(design_importer, "_call_vision",
|
||||||
|
lambda imgs, name: {"html": "<div>{{ headline }}</div>", "tokens": 50, "summary": ""})
|
||||||
|
|
||||||
|
design_importer.import_design_theme(str(tmp_theme))
|
||||||
|
# .bak.* 파일이 생성되었어야 함
|
||||||
|
backups = list(tmp_theme.glob("card.html.j2.bak.*"))
|
||||||
|
assert len(backups) == 1
|
||||||
|
assert backups[0].read_text(encoding="utf-8") == "OLD HTML"
|
||||||
|
# 새 파일은 새 내용
|
||||||
|
assert "headline" in (tmp_theme / "card.html.j2").read_text(encoding="utf-8")
|
||||||
71
insta-lab/tests/test_extract_with_weights.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import os
|
||||||
|
import gc
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db as db_module
|
||||||
|
from app import keyword_extractor
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_db(monkeypatch):
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(fd)
|
||||||
|
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||||
|
db_module.init_db()
|
||||||
|
yield path
|
||||||
|
gc.collect()
|
||||||
|
for ext in ("", "-wal", "-shm"):
|
||||||
|
try:
|
||||||
|
os.remove(path + ext)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_with_weights_proportional(tmp_db, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_extract(category, limit):
|
||||||
|
calls.append((category, limit))
|
||||||
|
return [{"id": i, "keyword": f"{category}{i}", "category": category, "score": 0.5}
|
||||||
|
for i in range(limit)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(keyword_extractor, "extract_for_category", fake_extract)
|
||||||
|
out = keyword_extractor.extract_with_weights(
|
||||||
|
{"economy": 0.6, "psychology": 0.3, "celebrity": 0.1}, total_limit=10,
|
||||||
|
)
|
||||||
|
by_cat = {c: l for c, l in calls}
|
||||||
|
assert by_cat == {"economy": 6, "psychology": 3, "celebrity": 1}
|
||||||
|
assert len(out) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_with_weights_skips_zero(tmp_db, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_extract(category, limit):
|
||||||
|
calls.append((category, limit))
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(keyword_extractor, "extract_for_category", fake_extract)
|
||||||
|
keyword_extractor.extract_with_weights(
|
||||||
|
{"economy": 1.0, "celebrity": 0.0}, total_limit=10,
|
||||||
|
)
|
||||||
|
cats_called = [c for c, _ in calls]
|
||||||
|
assert "celebrity" not in cats_called
|
||||||
|
assert "economy" in cats_called
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_with_weights_fallback_to_equal(tmp_db, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_extract(category, limit):
|
||||||
|
calls.append((category, limit))
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(keyword_extractor, "extract_for_category", fake_extract)
|
||||||
|
keyword_extractor.extract_with_weights({}, total_limit=9)
|
||||||
|
by_cat = {c: l for c, l in calls}
|
||||||
|
assert set(by_cat.keys()) == {"economy", "psychology", "celebrity"}
|
||||||
|
assert all(l == 3 for l in by_cat.values())
|
||||||
83
insta-lab/tests/test_main_trends.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import os
|
||||||
|
import gc
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import db as db_module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(fd)
|
||||||
|
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||||
|
db_module.init_db()
|
||||||
|
from app import main
|
||||||
|
monkeypatch.setattr(main, "DB_PATH", path)
|
||||||
|
with TestClient(main.app) as c:
|
||||||
|
yield c
|
||||||
|
gc.collect()
|
||||||
|
for ext in ("", "-wal", "-shm"):
|
||||||
|
try:
|
||||||
|
os.remove(path + ext)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_preferences_returns_defaults(client):
|
||||||
|
resp = client.get("/api/insta/preferences")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
cats = {p["category"]: p["weight"] for p in resp.json()["categories"]}
|
||||||
|
assert cats == {"economy": 1.0, "psychology": 1.0, "celebrity": 1.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_preferences_upsert(client):
|
||||||
|
resp = client.put("/api/insta/preferences",
|
||||||
|
json={"categories": {"economy": 0.7, "psychology": 0.2, "tech": 0.5}})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
cats = {p["category"]: p["weight"] for p in resp.json()["categories"]}
|
||||||
|
assert cats["economy"] == 0.7
|
||||||
|
assert cats["tech"] == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_trends_filter(client):
|
||||||
|
db_module.add_external_trend({"keyword": "A", "category": "economy",
|
||||||
|
"source": "naver_popular", "score": 1.0})
|
||||||
|
db_module.add_external_trend({"keyword": "B", "category": "celebrity",
|
||||||
|
"source": "google_trends", "score": 0.8})
|
||||||
|
resp = client.get("/api/insta/trends?source=naver_popular")
|
||||||
|
items = resp.json()["items"]
|
||||||
|
assert {it["keyword"] for it in items} == {"A"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_trends_kicks_background(client, monkeypatch):
|
||||||
|
from app import main, trend_collector
|
||||||
|
|
||||||
|
captured = {"called": False}
|
||||||
|
|
||||||
|
def fake_collect_all(cats):
|
||||||
|
captured["called"] = True
|
||||||
|
return {"naver_popular": 3, "youtube_trending": 2}
|
||||||
|
|
||||||
|
monkeypatch.setattr(trend_collector, "collect_all", fake_collect_all)
|
||||||
|
resp = client.post("/api/insta/trends/collect", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
task_id = resp.json()["task_id"]
|
||||||
|
for _ in range(20):
|
||||||
|
st = client.get(f"/api/insta/tasks/{task_id}").json()
|
||||||
|
if st["status"] in ("succeeded", "failed"):
|
||||||
|
break
|
||||||
|
assert st["status"] == "succeeded"
|
||||||
|
assert captured["called"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_keywords_filters_by_source(client):
|
||||||
|
db_module.add_trending_keyword({"keyword": "M", "category": "economy",
|
||||||
|
"score": 0.4, "articles_count": 1, "source": "manual"})
|
||||||
|
db_module.add_external_trend({"keyword": "N", "category": "economy",
|
||||||
|
"source": "naver_popular", "score": 0.9})
|
||||||
|
resp = client.get("/api/insta/keywords?source=manual")
|
||||||
|
items = resp.json()["items"]
|
||||||
|
assert {it["keyword"] for it in items} == {"M"}
|
||||||
77
insta-lab/tests/test_preferences_crud.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import os
|
||||||
|
import gc
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db as db_module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_db(monkeypatch):
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(fd)
|
||||||
|
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||||
|
db_module.init_db()
|
||||||
|
yield path
|
||||||
|
gc.collect()
|
||||||
|
for ext in ("", "-wal", "-shm"):
|
||||||
|
try:
|
||||||
|
os.remove(path + ext)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_db_creates_account_preferences(tmp_db):
|
||||||
|
with db_module._conn() as conn:
|
||||||
|
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||||
|
names = {r[0] for r in rows}
|
||||||
|
assert "account_preferences" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_db_seeds_default_weights(tmp_db):
|
||||||
|
prefs = db_module.get_preferences()
|
||||||
|
cats = {p["category"]: p["weight"] for p in prefs}
|
||||||
|
assert cats["economy"] == pytest.approx(1.0)
|
||||||
|
assert cats["psychology"] == pytest.approx(1.0)
|
||||||
|
assert cats["celebrity"] == pytest.approx(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_preferences_replaces_weights(tmp_db):
|
||||||
|
db_module.upsert_preferences({"economy": 0.6, "psychology": 0.3, "celebrity": 0.1, "tech": 0.5})
|
||||||
|
prefs = {p["category"]: p["weight"] for p in db_module.get_preferences()}
|
||||||
|
assert prefs["economy"] == pytest.approx(0.6)
|
||||||
|
assert prefs["tech"] == pytest.approx(0.5)
|
||||||
|
assert "celebrity" in prefs and prefs["celebrity"] == pytest.approx(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trending_keywords_source_column_exists(tmp_db):
|
||||||
|
with db_module._conn() as conn:
|
||||||
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(trending_keywords)").fetchall()]
|
||||||
|
assert "source" in cols
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_trending_keyword_default_source(tmp_db):
|
||||||
|
kid = db_module.add_trending_keyword({
|
||||||
|
"keyword": "K", "category": "economy", "score": 0.5, "articles_count": 3,
|
||||||
|
})
|
||||||
|
with db_module._conn() as conn:
|
||||||
|
row = conn.execute("SELECT source FROM trending_keywords WHERE id=?", (kid,)).fetchone()
|
||||||
|
assert row[0] == "manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_external_trend_stores_source(tmp_db):
|
||||||
|
tid = db_module.add_external_trend({
|
||||||
|
"keyword": "급등주", "category": "economy", "source": "naver_popular", "score": 0.9,
|
||||||
|
})
|
||||||
|
rows = db_module.list_trends(source="naver_popular")
|
||||||
|
assert any(r["id"] == tid and r["keyword"] == "급등주" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_trends_filters_by_source_and_category(tmp_db):
|
||||||
|
db_module.add_external_trend({"keyword": "A", "category": "economy", "source": "naver_popular", "score": 1.0})
|
||||||
|
db_module.add_external_trend({"keyword": "B", "category": "celebrity", "source": "google_trends", "score": 1.0})
|
||||||
|
only_naver = db_module.list_trends(source="naver_popular")
|
||||||
|
assert {r["keyword"] for r in only_naver} == {"A"}
|
||||||
|
only_celeb_google = db_module.list_trends(source="google_trends", category="celebrity")
|
||||||
|
assert {r["keyword"] for r in only_celeb_google} == {"B"}
|
||||||
160
insta-lab/tests/test_trend_collector.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import os
|
||||||
|
import gc
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db as db_module
|
||||||
|
from app import trend_collector
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_db(monkeypatch):
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(fd)
|
||||||
|
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||||
|
db_module.init_db()
|
||||||
|
yield path
|
||||||
|
gc.collect()
|
||||||
|
for ext in ("", "-wal", "-shm"):
|
||||||
|
try:
|
||||||
|
os.remove(path + ext)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
NAVER_RESPONSE = {
|
||||||
|
"items": [
|
||||||
|
{"title": "<b>기준금리</b> 인상", "link": "https://n.news.naver.com/a/1", "description": "한국은행 발표"},
|
||||||
|
{"title": "환율 급등", "link": "https://n.news.naver.com/a/2", "description": "달러 강세"},
|
||||||
|
{"title": "기준금리 추가 인상", "link": "https://n.news.naver.com/a/3", "description": "추가 발표"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_naver_popular_extracts_top_terms(tmp_db, monkeypatch):
|
||||||
|
fake_resp = MagicMock()
|
||||||
|
fake_resp.json.return_value = NAVER_RESPONSE
|
||||||
|
fake_resp.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
with patch.object(trend_collector.requests, "get", return_value=fake_resp):
|
||||||
|
trends = trend_collector.fetch_naver_popular("economy", per_seed=10, top_n=5)
|
||||||
|
|
||||||
|
keywords = [t["keyword"] for t in trends]
|
||||||
|
assert "기준금리" in keywords
|
||||||
|
for t in trends:
|
||||||
|
assert t["category"] == "economy"
|
||||||
|
assert t["source"] == "naver_popular"
|
||||||
|
assert 0.0 <= t["score"] <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_naver_writes_to_db(tmp_db, monkeypatch):
|
||||||
|
fake_resp = MagicMock()
|
||||||
|
fake_resp.json.return_value = NAVER_RESPONSE
|
||||||
|
fake_resp.raise_for_status.return_value = None
|
||||||
|
with patch.object(trend_collector.requests, "get", return_value=fake_resp):
|
||||||
|
n = trend_collector.collect_naver_popular_for(["economy"])
|
||||||
|
assert n > 0
|
||||||
|
rows = db_module.list_trends(source="naver_popular")
|
||||||
|
assert len(rows) > 0
|
||||||
|
assert all(r["source"] == "naver_popular" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_keyword_with_cache(monkeypatch):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake_claude(keyword: str) -> str:
|
||||||
|
calls["n"] += 1
|
||||||
|
return "economy"
|
||||||
|
|
||||||
|
monkeypatch.setattr(trend_collector, "_llm_classify_one", fake_claude)
|
||||||
|
trend_collector._category_cache.clear()
|
||||||
|
|
||||||
|
c1 = trend_collector.classify_keyword("기준금리")
|
||||||
|
c2 = trend_collector.classify_keyword("기준금리")
|
||||||
|
assert c1 == c2 == "economy"
|
||||||
|
assert calls["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_youtube_trending_parses_and_cleans_titles(tmp_db, monkeypatch):
|
||||||
|
"""YouTube Data API mostPopular 응답 → 제목 정제 + 분류."""
|
||||||
|
monkeypatch.setattr(trend_collector, "YOUTUBE_DATA_API_KEY", "fake_key")
|
||||||
|
payload = {
|
||||||
|
"items": [
|
||||||
|
{"snippet": {"title": "[속보] 기준금리 인상 단행 🔥"}},
|
||||||
|
{"snippet": {"title": "(공식) BTS 컴백 무대 🎤"}},
|
||||||
|
{"snippet": {"title": "스트레스 관리 5가지 방법"}},
|
||||||
|
# 중복 제목 — 중복 제거 확인
|
||||||
|
{"snippet": {"title": "[속보] 기준금리 인상 단행 🔥"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
fake_resp = MagicMock()
|
||||||
|
fake_resp.json.return_value = payload
|
||||||
|
fake_resp.raise_for_status.return_value = None
|
||||||
|
monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
trend_collector, "classify_keyword",
|
||||||
|
lambda kw: ("economy" if "금리" in kw else
|
||||||
|
"celebrity" if "BTS" in kw else
|
||||||
|
"psychology" if "스트레스" in kw else "uncategorized"),
|
||||||
|
)
|
||||||
|
|
||||||
|
trends = trend_collector.fetch_youtube_trending()
|
||||||
|
keywords = [t["keyword"] for t in trends]
|
||||||
|
assert "기준금리 인상 단행" in keywords # 대괄호·이모지 제거
|
||||||
|
assert "BTS 컴백 무대" in keywords # 괄호 제거
|
||||||
|
assert "스트레스 관리 5가지 방법" in keywords # 그대로
|
||||||
|
assert len(trends) == 3 # 중복 제거됨
|
||||||
|
assert all(t["source"] == "youtube_trending" for t in trends)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_youtube_trending_no_api_key_returns_empty(monkeypatch):
|
||||||
|
monkeypatch.setattr(trend_collector, "YOUTUBE_DATA_API_KEY", "")
|
||||||
|
out = trend_collector.fetch_youtube_trending()
|
||||||
|
assert out == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_youtube_trending_graceful_on_api_failure(monkeypatch):
|
||||||
|
monkeypatch.setattr(trend_collector, "YOUTUBE_DATA_API_KEY", "fake_key")
|
||||||
|
fake_resp = MagicMock()
|
||||||
|
fake_resp.raise_for_status.side_effect = RuntimeError("quota exceeded")
|
||||||
|
monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp)
|
||||||
|
out = trend_collector.fetch_youtube_trending()
|
||||||
|
assert out == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_all_invokes_both_sources(tmp_db, monkeypatch):
|
||||||
|
monkeypatch.setattr(trend_collector, "collect_naver_popular_for",
|
||||||
|
lambda cats: 5)
|
||||||
|
monkeypatch.setattr(trend_collector, "collect_youtube_trending",
|
||||||
|
lambda: 3)
|
||||||
|
out = trend_collector.collect_all(["economy"])
|
||||||
|
assert out == {"naver_popular": 5, "youtube_trending": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeds_for_filters_placeholder(tmp_db, monkeypatch):
|
||||||
|
"""category_seeds 템플릿에 placeholder '...'가 들어가도 DEFAULT 폴백."""
|
||||||
|
from app import db as db_module
|
||||||
|
db_module.upsert_prompt_template(
|
||||||
|
"category_seeds",
|
||||||
|
'{"economy": ["...", "…", "a", "real_keyword"]}',
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
out = trend_collector._seeds_for("economy")
|
||||||
|
# '...', '…', 'a'(2자 미만)는 필터링되고 'real_keyword'만 남음
|
||||||
|
assert out == ["real_keyword"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeds_for_falls_back_when_all_invalid(tmp_db, monkeypatch):
|
||||||
|
"""모든 시드가 invalid면 DEFAULT_CATEGORY_SEEDS 폴백."""
|
||||||
|
from app import db as db_module
|
||||||
|
db_module.upsert_prompt_template(
|
||||||
|
"category_seeds",
|
||||||
|
'{"economy": ["...", "TBD", ""]}',
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
out = trend_collector._seeds_for("economy")
|
||||||
|
# DEFAULT_CATEGORY_SEEDS["economy"] 가 반환되어야 함
|
||||||
|
from app.config import DEFAULT_CATEGORY_SEEDS
|
||||||
|
assert out == list(DEFAULT_CATEGORY_SEEDS["economy"])
|
||||||
@@ -133,8 +133,12 @@ async def sign_link(
|
|||||||
|
|
||||||
# 경로 안전: PACK_HOST_DIR(NAS 호스트 절대경로) 하위인지 확인.
|
# 경로 안전: PACK_HOST_DIR(NAS 호스트 절대경로) 하위인지 확인.
|
||||||
# file_path는 upload 라우트가 Supabase에 저장한 호스트경로 그대로 전달되어 DSM API에 사용됨.
|
# file_path는 upload 라우트가 Supabase에 저장한 호스트경로 그대로 전달되어 DSM API에 사용됨.
|
||||||
|
# str.startswith는 '/foo/packs' 와 '/foo/packs_evil' 같은 sibling 경로를 통과시키므로
|
||||||
|
# Path.relative_to로 엄격하게 컴포넌트 단위 검증한다 (CODE_REVIEW F1).
|
||||||
abs_path = Path(payload.file_path).resolve()
|
abs_path = Path(payload.file_path).resolve()
|
||||||
if not str(abs_path).startswith(str(PACK_HOST_DIR)):
|
try:
|
||||||
|
abs_path.relative_to(PACK_HOST_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail="허용된 경로 외부")
|
raise HTTPException(status_code=400, detail="허용된 경로 외부")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -60,6 +60,29 @@ def test_sign_link_path_outside_base():
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_link_rejects_sibling_path():
|
||||||
|
"""PACK_HOST_DIR='/foo/packs' 일 때 '/foo/packs_evil/x.mp4' 같이 prefix만
|
||||||
|
통과하는 sibling 경로는 거부해야 한다 (CODE_REVIEW F1, path traversal 변형).
|
||||||
|
|
||||||
|
기존 str.startswith 방식은 trailing slash가 없어 sibling 경로를 통과시킴.
|
||||||
|
relative_to 기반 검증으로 교체되어야 통과한다.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
from pathlib import Path
|
||||||
|
base_resolved = Path("/foo/packs").resolve()
|
||||||
|
# base의 자식이 아닌 sibling 경로 (예: /foo/packs_evil/...)
|
||||||
|
sibling_posix = (base_resolved.parent / f"{base_resolved.name}_evil" / "x.mp4").as_posix()
|
||||||
|
with patch("app.routes.PACK_HOST_DIR", base_resolved):
|
||||||
|
body = _json.dumps(
|
||||||
|
{"file_path": sibling_posix, "expires_in_seconds": 14400}
|
||||||
|
).encode()
|
||||||
|
r = client.post("/api/packs/sign-link", content=body, headers=_signed(body))
|
||||||
|
assert r.status_code == 400, (
|
||||||
|
f"sibling 경로 '{sibling_posix}'가 허용됨 (status={r.status_code}) "
|
||||||
|
f"— path traversal 가능성"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_upload_invalid_token():
|
def test_upload_invalid_token():
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/packs/upload",
|
"/api/packs/upload",
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── docker / compose / buildkit timeout 늘리기 ──
|
||||||
|
# NAS Celeron J4025에서 pip install·chromium 다운로드 등 무거운 RUN step이
|
||||||
|
# 기본 timeout(2분)에 걸려 webhook 자동 배포가 "DeadlineExceeded"로 끝나는 일이
|
||||||
|
# 있어 10분으로 상향. 호스트 셸 + deployer 컨테이너 둘 다에 적용됨.
|
||||||
|
export COMPOSE_HTTP_TIMEOUT=600
|
||||||
|
export DOCKER_CLIENT_TIMEOUT=600
|
||||||
|
export BUILDKIT_STEP_LOG_MAX_SIZE=-1
|
||||||
|
|
||||||
# ── 동시 배포 방지 (flock) ──
|
# ── 동시 배포 방지 (flock) ──
|
||||||
exec 200>/tmp/deploy.lock
|
exec 200>/tmp/deploy.lock
|
||||||
flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ KB증권·삼성증권 등 Open API 미제공 증권사용.
|
|||||||
"name": "삼성전자",
|
"name": "삼성전자",
|
||||||
"quantity": 100,
|
"quantity": 100,
|
||||||
"avg_price": 72000,
|
"avg_price": 72000,
|
||||||
|
"purchase_price": 72000,
|
||||||
"current_price": 74500,
|
"current_price": 74500,
|
||||||
"price_session": "NXT_AFTER",
|
"price_session": "NXT_AFTER",
|
||||||
"price_as_of": "2026-05-11T19:21:40+09:00",
|
"price_as_of": "2026-05-11T19:21:40+09:00",
|
||||||
@@ -159,6 +160,10 @@ KB증권·삼성증권 등 Open API 미제공 증권사용.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **`purchase_price` 필드**: 종목별 매입 단가(1주당). 사용자가 수동 등록한 매입가가
|
||||||
|
> 평균단가(`avg_price`)와 다를 때 표시용으로 분리한다. 미설정 시 `avg_price`로 폴백.
|
||||||
|
> `summary.total_buy = SUM(purchase_price × quantity)` (CODE_REVIEW F4에서 명세 정합화).
|
||||||
|
|
||||||
> **주의**: 현재가 조회에 실패한 종목은 `current_price`, `eval_amount`, `profit_amount`, `profit_rate` 가 `null`로 반환됩니다.
|
> **주의**: 현재가 조회에 실패한 종목은 `current_price`, `eval_amount`, `profit_amount`, `profit_rate` 가 `null`로 반환됩니다.
|
||||||
> 프론트에서 `null` 체크 후 `"조회 실패"` 등으로 표시해 주세요.
|
> 프론트에서 `null` 체크 후 `"조회 실패"` 등으로 표시해 주세요.
|
||||||
|
|
||||||
|
|||||||
@@ -47,13 +47,30 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
|||||||
# Windows AI Server URL (NAS .env에서 설정)
|
# Windows AI Server URL (NAS .env에서 설정)
|
||||||
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
||||||
|
|
||||||
# Admin API Key 인증
|
# Admin API Key 인증 — /api/trade/* 보호 (CODE_REVIEW F2)
|
||||||
|
# 빈 키 + 명시적 dev flag 없으면 503으로 거부. 운영 .env에 ADMIN_API_KEY 누락 시
|
||||||
|
# 무인증 통과되던 버그 차단.
|
||||||
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
|
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
|
||||||
|
|
||||||
def verify_admin(x_admin_key: str = Header(None)):
|
def verify_admin(x_admin_key: str = Header(None)):
|
||||||
"""admin/trade 엔드포인트 보호용 API 키 검증"""
|
"""admin/trade 엔드포인트 보호용 API 키 검증.
|
||||||
|
|
||||||
|
- ADMIN_API_KEY 설정됨 + 키 일치 → 통과
|
||||||
|
- ADMIN_API_KEY 설정됨 + 키 불일치 → 401 Unauthorized
|
||||||
|
- ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (개발 모드)
|
||||||
|
- ADMIN_API_KEY 미설정 + dev flag 없음 → 503 (보호 강화, 운영 .env 누락 차단)
|
||||||
|
"""
|
||||||
if not ADMIN_API_KEY:
|
if not ADMIN_API_KEY:
|
||||||
return # 키 미설정 시 인증 비활성화 (개발 환경)
|
if os.getenv("ALLOW_UNAUTHENTICATED_ADMIN", "false").lower() == "true":
|
||||||
|
return # 개발 환경 명시적 허용
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=(
|
||||||
|
"admin endpoint protected — ADMIN_API_KEY not configured. "
|
||||||
|
"Set ADMIN_API_KEY in .env, or set ALLOW_UNAUTHENTICATED_ADMIN=true "
|
||||||
|
"for development only."
|
||||||
|
),
|
||||||
|
)
|
||||||
if x_admin_key != ADMIN_API_KEY:
|
if x_admin_key != ADMIN_API_KEY:
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
@@ -337,11 +354,11 @@ def get_portfolio():
|
|||||||
price_session = detail["session"] if detail else None
|
price_session = detail["session"] if detail else None
|
||||||
price_as_of = detail["as_of"] if detail else None
|
price_as_of = detail["as_of"] if detail else None
|
||||||
# avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준
|
# avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준
|
||||||
# purchase_price: 매입가 — 총 매입 금액 표시 기준 (없으면 avg_price로 폴백)
|
# purchase_price: 매입 단가(1주당) — 없으면 avg_price로 폴백 (CODE_REVIEW F4)
|
||||||
purchase_price = item.get("purchase_price") if item.get("purchase_price") is not None else item["avg_price"]
|
purchase_price = item.get("purchase_price") if item.get("purchase_price") is not None else item["avg_price"]
|
||||||
cost_basis = item["avg_price"] * item["quantity"]
|
cost_basis = item["avg_price"] * item["quantity"]
|
||||||
# 총 매입 금액 표시는 종목별 매입가의 단순 합계 (수량 미곱산)
|
# 총 매입 금액 = 단가 × 보유 수량. API_SPEC.md 예시(qty 100·avg 72000 → 7,200,000)와 일치
|
||||||
buy_amount = purchase_price
|
buy_amount = purchase_price * item["quantity"]
|
||||||
eval_amount = current_price * item["quantity"] if current_price is not None else None
|
eval_amount = current_price * item["quantity"] if current_price is not None else None
|
||||||
profit_amount = (eval_amount - cost_basis) if eval_amount is not None else None
|
profit_amount = (eval_amount - cost_basis) if eval_amount is not None else None
|
||||||
profit_rate = round((profit_amount / cost_basis) * 100, 2) if (profit_amount is not None and cost_basis) else None
|
profit_rate = round((profit_amount / cost_basis) * 100, 2) if (profit_amount is not None and cost_basis) else None
|
||||||
|
|||||||
3
stock/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
asyncio_mode = auto
|
||||||
43
stock/tests/test_admin_auth.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""verify_admin 보안 강화 회귀 테스트 (CODE_REVIEW F2).
|
||||||
|
|
||||||
|
운영 .env에서 ADMIN_API_KEY가 누락되면 /api/trade/balance, /api/trade/order
|
||||||
|
인증이 무력화되는 버그를 막기 위한 가드.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app import main as stock_main
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_admin_rejects_when_key_missing_and_no_dev_flag(monkeypatch):
|
||||||
|
"""ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN 미설정 → 503."""
|
||||||
|
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "")
|
||||||
|
monkeypatch.delenv("ALLOW_UNAUTHENTICATED_ADMIN", raising=False)
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
stock_main.verify_admin(x_admin_key=None)
|
||||||
|
assert exc_info.value.status_code == 503
|
||||||
|
assert "ADMIN_API_KEY" in exc_info.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_admin_allows_when_key_missing_with_dev_flag(monkeypatch):
|
||||||
|
"""ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (개발 모드)."""
|
||||||
|
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "")
|
||||||
|
monkeypatch.setenv("ALLOW_UNAUTHENTICATED_ADMIN", "true")
|
||||||
|
stock_main.verify_admin(x_admin_key=None) # 예외 없으면 통과
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_admin_rejects_wrong_key(monkeypatch):
|
||||||
|
"""ADMIN_API_KEY 설정 + 잘못된 키 → 401 (regression)."""
|
||||||
|
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "secret123")
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
stock_main.verify_admin(x_admin_key="wrong")
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_admin_allows_correct_key(monkeypatch):
|
||||||
|
"""ADMIN_API_KEY 설정 + 올바른 키 → 통과 (regression)."""
|
||||||
|
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "secret123")
|
||||||
|
stock_main.verify_admin(x_admin_key="secret123") # 예외 없으면 통과
|
||||||
77
stock/tests/test_portfolio_total_buy.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""포트폴리오 /api/portfolio 응답의 total_buy 계산 회귀 테스트 (CODE_REVIEW F4).
|
||||||
|
|
||||||
|
purchase_price는 종목별 단가(1주당) 의미. total_buy = SUM(purchase_price × quantity).
|
||||||
|
purchase_price가 없으면 avg_price로 폴백 후 동일하게 수량 곱산.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_db_setup(monkeypatch, items, cash=None):
|
||||||
|
from app import main as stock_main
|
||||||
|
monkeypatch.setattr(stock_main, "get_all_portfolio", lambda: items)
|
||||||
|
monkeypatch.setattr(stock_main, "get_all_broker_cash", lambda: cash or [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_portfolio_total_buy_uses_purchase_price_times_quantity(monkeypatch):
|
||||||
|
"""purchase_price 설정 시: total_buy = purchase_price × quantity 의 합."""
|
||||||
|
items = [
|
||||||
|
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
|
||||||
|
"quantity": 100, "avg_price": 72000, "purchase_price": 70000},
|
||||||
|
]
|
||||||
|
fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}}
|
||||||
|
_fake_db_setup(monkeypatch, items)
|
||||||
|
from app import main as stock_main
|
||||||
|
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/portfolio")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# purchase_price=70000 × quantity=100 = 7,000,000
|
||||||
|
assert data["summary"]["total_buy"] == 7_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_portfolio_total_buy_falls_back_to_avg_price_with_quantity(monkeypatch):
|
||||||
|
"""purchase_price 미설정 시: avg_price 폴백 + 수량 곱산. API_SPEC 예시와 일치."""
|
||||||
|
items = [
|
||||||
|
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
|
||||||
|
"quantity": 100, "avg_price": 72000, "purchase_price": None},
|
||||||
|
]
|
||||||
|
fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}}
|
||||||
|
_fake_db_setup(monkeypatch, items)
|
||||||
|
from app import main as stock_main
|
||||||
|
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/portfolio")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# avg_price=72000 × quantity=100 = 7,200,000 (API_SPEC.md 예시와 일치)
|
||||||
|
assert data["summary"]["total_buy"] == 7_200_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_portfolio_total_buy_sums_multiple_holdings(monkeypatch):
|
||||||
|
"""여러 종목 합산도 단가 × 수량 합."""
|
||||||
|
items = [
|
||||||
|
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
|
||||||
|
"quantity": 100, "avg_price": 70000, "purchase_price": 70000},
|
||||||
|
{"id": 2, "broker": "NH", "ticker": "000660", "name": "SK하이닉스",
|
||||||
|
"quantity": 50, "avg_price": 130000, "purchase_price": 130000},
|
||||||
|
]
|
||||||
|
fake_prices = {
|
||||||
|
"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"},
|
||||||
|
"000660": {"price": 140000, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"},
|
||||||
|
}
|
||||||
|
_fake_db_setup(monkeypatch, items)
|
||||||
|
from app import main as stock_main
|
||||||
|
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/portfolio")
|
||||||
|
data = resp.json()
|
||||||
|
# 70000*100 + 130000*50 = 7,000,000 + 6,500,000 = 13,500,000
|
||||||
|
assert data["summary"]["total_buy"] == 13_500_000
|
||||||