Compare commits
101 Commits
4a333434ac
...
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 | |||
| e3348da642 | |||
| 088bbaa097 | |||
| be322557ee | |||
| 70438caa1f | |||
| e16029ebdb | |||
| cefc3119c0 | |||
| 5485d4858a | |||
| fbd963db86 | |||
| 9095423026 | |||
| 6eb24090ed | |||
| 8cb5a01431 | |||
| 8a4a8790ca | |||
| 2200748122 | |||
| 7bc0a7cd77 | |||
| b84efd730b | |||
| 11bd223612 | |||
| c3a5d7210f | |||
| 07c4459085 | |||
| c057304981 | |||
| d1245d040c | |||
| 34ca407ca2 | |||
| b1ef778fc5 | |||
| 30706e2eb6 | |||
| 6062445c12 | |||
| 13da2226c3 | |||
| 1e377e1559 | |||
| eb75d692f5 | |||
| 6c25866487 | |||
| 6ac7469f26 | |||
| d1b2b6a4ba | |||
| 2abfa5cb23 | |||
| 227e294bd3 | |||
| ace0339d33 | |||
| 8812bd870a | |||
| b3fac4f442 | |||
| 19aed304cb | |||
| bbe5221e57 | |||
| ec0ccf649e | |||
| 84d90f6e1c | |||
| ddfe0ca3eb | |||
| 943f676414 | |||
| 06162b1e6e | |||
| c3659eb6c5 | |||
| 16941d76e8 | |||
| 9f91dae1a4 | |||
| 2a552d3cc8 | |||
| f37b21a408 | |||
| df7a8d985e | |||
| c5d0c84183 | |||
| 53a78a1062 | |||
| ca8bcb3fed | |||
| 4b4f91c052 | |||
| 6c3a84b8ec | |||
| 2ff2645240 | |||
| f2143b3889 | |||
| 810cc76d40 | |||
| 0a91f43c46 | |||
| 3d321f2b4b | |||
| 6ba29599aa | |||
| 658ed13571 | |||
| 15ee3c3301 | |||
| 2b5009f864 | |||
| d9b612253a | |||
| db4322006d | |||
| a05e6ba8ca |
12
.env.example
@@ -51,9 +51,14 @@ PGID=1000
|
||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||
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 비워둔 채로 trade/admin 엔드포인트 호출 허용.
|
||||
# 운영 환경에서는 절대 true로 두지 말 것. 기본 false (보호 활성).
|
||||
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||
|
||||
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
|
||||
ANTHROPIC_API_KEY=
|
||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||
@@ -119,5 +124,6 @@ PACK_DATA_PATH=./data/packs
|
||||
PACK_BASE_DIR=/app/data/packs
|
||||
|
||||
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
|
||||
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정. 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
|
||||
PACK_HOST_DIR=/volume1/docker/webpage/media/packs
|
||||
# 운영 NAS는 반드시 /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
|
||||
.worktrees/
|
||||
|
||||
################################
|
||||
# Local working files
|
||||
################################
|
||||
# Superpowers 스킬 캐시·세션 메타
|
||||
.superpowers/
|
||||
# 임시 코드 리뷰 노트 (작업 끝나면 폐기 또는 docs/로 이동)
|
||||
CODE_REVIEW.md
|
||||
|
||||
127
CLAUDE.md
@@ -7,7 +7,7 @@
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
- **서비스**: lotto-lab, stock, travel-proxy, music-lab, insta-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
|
||||
@@ -32,7 +32,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
/volume1
|
||||
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
|
||||
│ ├── lotto/ # lotto 소스 (rsync 동기화)
|
||||
│ ├── stock-lab/ # stock-lab 소스 (rsync 동기화)
|
||||
│ ├── stock/ # stock 소스 (rsync 동기화)
|
||||
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
|
||||
│ ├── deployer/ # deployer 소스 (rsync 동기화)
|
||||
│ ├── nginx/default.conf # Nginx 설정
|
||||
@@ -54,9 +54,9 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
||||
| `stock` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
||||
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
|
||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 API |
|
||||
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드) |
|
||||
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
|
||||
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||
@@ -73,11 +73,11 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
|------|------------|------|
|
||||
| `/api/` | `lotto:8000` | lotto API (기본) |
|
||||
| `/api/travel/` | `travel-proxy:8000` | travel API |
|
||||
| `/api/stock/` | `stock-lab:8000` | stock API |
|
||||
| `/api/trade/` | `stock-lab:8000` | KIS 실계좌 API |
|
||||
| `/api/portfolio` | `stock-lab:8000` | trailing slash 유무 모두 매칭 |
|
||||
| `/api/stock/` | `stock:8000` | stock API |
|
||||
| `/api/trade/` | `stock:8000` | KIS 실계좌 API |
|
||||
| `/api/portfolio` | `stock:8000` | trailing slash 유무 모두 매칭 |
|
||||
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
|
||||
| `/api/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 API |
|
||||
| `/api/insta/` | `insta-lab:8000` | 인스타 카드 자동 생성 API |
|
||||
| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
|
||||
| `/api/todos` | `personal:8000` | 투두 API |
|
||||
| `/api/blog/` | `personal:8000` | 블로그 API |
|
||||
@@ -135,7 +135,7 @@ docker compose up -d
|
||||
| Lotto Backend | http://localhost:18000 |
|
||||
| Travel API | http://localhost:19000 |
|
||||
| Stock Lab | http://localhost:18500 |
|
||||
| Blog Lab | http://localhost:18700 |
|
||||
| Insta Lab | http://localhost:18700 |
|
||||
| Realestate Lab | http://localhost:18800 |
|
||||
| Packs Lab | http://localhost:18950 |
|
||||
|
||||
@@ -205,14 +205,14 @@ docker compose up -d
|
||||
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
||||
| GET | `/api/lotto/briefing` | 브리핑 이력 |
|
||||
|
||||
### stock-lab (stock-lab/)
|
||||
### stock (stock/)
|
||||
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
|
||||
- KIS API 연동으로 실계좌 잔고·거래 조회
|
||||
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트
|
||||
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
|
||||
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json`
|
||||
|
||||
**stock-lab API 목록**
|
||||
**stock API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
@@ -454,65 +454,67 @@ docker compose up -d
|
||||
| PUT | `/api/travel/albums/{album}/region` | 앨범 지역 변경 (region_map_extra 수정) |
|
||||
| PUT | `/api/travel/regions/{region_id}` | 커스텀 지역 이름/좌표 수정 (지도 핀 표시용) |
|
||||
|
||||
### blog-lab (blog-lab/)
|
||||
- 블로그 마케팅 수익화 서비스 (키워드 분석 → AI 글 생성 → 마케팅 강화 → 품질 리뷰 → 포스팅 → 수익 추적)
|
||||
- AI 엔진: Claude API (Anthropic, `claude-sonnet-4-20250514`)
|
||||
- 웹 검색: Naver Search API (블로그 + 쇼핑) + 상위 블로그 본문 크롤링
|
||||
- DB: `/app/data/blog_marketing.db`
|
||||
- 파일 구조: `main.py`, `db.py`, `config.py`, `naver_search.py`, `content_generator.py`, `marketer.py`, `quality_reviewer.py`, `web_crawler.py`
|
||||
### insta-lab (insta-lab/)
|
||||
- 인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피 + PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드
|
||||
- DB: `/app/data/insta.db` (news_articles, trending_keywords, card_slates, card_assets, generation_tasks, prompt_templates)
|
||||
- 카드 사이즈: 1080×1350 (인스타 4:5 세로)
|
||||
- 카드 렌더: Jinja2 템플릿 → Playwright headless Chromium 스크린샷
|
||||
- 파일 구조: `app/main.py`, `config.py`, `db.py`, `news_collector.py`, `keyword_extractor.py`, `card_writer.py`, `card_renderer.py`, `templates/default/card.html.j2`
|
||||
|
||||
**파이프라인**: 리서치(+크롤링) → 작가(초안) → 마케터(링크 삽입) → 평가자(6기준 60점)
|
||||
**상태 흐름**: `draft` → `marketed` → `reviewed` → `published`
|
||||
**환경변수**
|
||||
- `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET`: 네이버 검색 API
|
||||
- `ANTHROPIC_API_KEY`: Claude API (Haiku=키워드 정제, Sonnet=카드 카피)
|
||||
- `ANTHROPIC_MODEL_HAIKU` / `ANTHROPIC_MODEL_SONNET`: 모델명 오버라이드
|
||||
- `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`)
|
||||
- `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`)
|
||||
- `INSTA_DEFAULT_THEME`: 카드 렌더에 사용할 theme 디렉토리명 (기본 `default`). `templates/<theme>/card.html.j2`가 없으면 자동으로 default 폴백
|
||||
- `NEWS_PER_CATEGORY` / `KEYWORDS_PER_CATEGORY`: 수집·추출 limit 튜닝
|
||||
|
||||
**blog_marketing.db 테이블**
|
||||
**카테고리 시드 키워드**
|
||||
- 기본 economy / psychology / celebrity 3종 (config.DEFAULT_CATEGORY_SEEDS)
|
||||
- `prompt_templates.name='category_seeds'`에 JSON으로 오버라이드 가능
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `keyword_analyses` | 키워드 분석 결과 (네이버 검색 데이터 + 경쟁도/기회 점수 + 크롤링 본문) |
|
||||
| `blog_posts` | 블로그 글 (draft → marketed → reviewed → published) |
|
||||
| `brand_links` | 브랜드커넥트 제휴 링크 (post_id/keyword_id FK) |
|
||||
| `commissions` | 포스트별 월간 클릭/구매/수익 |
|
||||
| `generation_tasks` | 비동기 작업 상태 (research/generate/market/review) |
|
||||
| `prompt_templates` | AI 프롬프트 템플릿 (DB 저장, 코드 배포 없이 수정 가능) |
|
||||
**카드 슬레이트 (`card_slates`)**
|
||||
- status: `draft` → `rendered` → `sent` (또는 `failed`)
|
||||
- cover_copy / body_copies (8개) / cta_copy / suggested_caption / hashtags JSON 컬럼
|
||||
- accent_color는 카테고리별 기본값 (economy=#0F62FE, psychology=#A66CFF, celebrity=#FF5C8A)
|
||||
|
||||
**blog-lab API 목록**
|
||||
**스케줄러 job (agent-office)**
|
||||
- 09:30 매일 — `_run_insta_schedule` (insta_pipeline) → 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시
|
||||
- `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 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/blog-marketing/status` | 서비스 상태 (API 키 설정 현황) |
|
||||
| POST | `/api/blog-marketing/research` | 키워드 분석 시작 (+ 상위 블로그 크롤링) |
|
||||
| GET | `/api/blog-marketing/research/history` | 분석 이력 조회 |
|
||||
| GET | `/api/blog-marketing/research/{id}` | 분석 상세 조회 |
|
||||
| DELETE | `/api/blog-marketing/research/{id}` | 분석 삭제 |
|
||||
| GET | `/api/blog-marketing/task/{task_id}` | 작업 상태 폴링 |
|
||||
| POST | `/api/blog-marketing/generate` | 작가 단계: AI 글 생성 (크롤링 참고 + 링크 반영) |
|
||||
| POST | `/api/blog-marketing/market/{post_id}` | 마케터 단계: 전환율 강화 + 링크 삽입 |
|
||||
| POST | `/api/blog-marketing/review/{post_id}` | 평가자 단계: 품질 리뷰 (6기준 × 10점, 42/60 통과) |
|
||||
| POST | `/api/blog-marketing/regenerate/{post_id}` | 피드백 기반 재생성 |
|
||||
| POST | `/api/blog-marketing/links` | 브랜드커넥트 링크 등록 |
|
||||
| GET | `/api/blog-marketing/links` | 링크 조회 (post_id, keyword_id 필터) |
|
||||
| PUT | `/api/blog-marketing/links/{id}` | 링크 수정 |
|
||||
| DELETE | `/api/blog-marketing/links/{id}` | 링크 삭제 |
|
||||
| GET | `/api/blog-marketing/posts` | 포스트 목록 (status 필터) |
|
||||
| GET | `/api/blog-marketing/posts/{id}` | 포스트 상세 |
|
||||
| PUT | `/api/blog-marketing/posts/{id}` | 포스트 수정 |
|
||||
| DELETE | `/api/blog-marketing/posts/{id}` | 포스트 삭제 |
|
||||
| POST | `/api/blog-marketing/posts/{id}/publish` | 발행 (네이버 URL 등록) |
|
||||
| GET | `/api/blog-marketing/commissions` | 수익 내역 조회 |
|
||||
| POST | `/api/blog-marketing/commissions` | 수익 기록 추가 |
|
||||
| PUT | `/api/blog-marketing/commissions/{id}` | 수익 기록 수정 |
|
||||
| DELETE | `/api/blog-marketing/commissions/{id}` | 수익 기록 삭제 |
|
||||
| GET | `/api/blog-marketing/dashboard` | 대시보드 집계 |
|
||||
|
||||
**환경변수**
|
||||
- `ANTHROPIC_API_KEY`: Claude API 키 (미설정 시 AI 생성 비활성화)
|
||||
- `NAVER_CLIENT_ID`: 네이버 검색 API 클라이언트 ID
|
||||
- `NAVER_CLIENT_SECRET`: 네이버 검색 API 시크릿
|
||||
- `BLOG_DATA_PATH`: SQLite DB 저장 경로 (기본 `./data/blog`)
|
||||
| GET | `/api/insta/status` | 서비스 상태 (NAVER/ANTHROPIC 키 여부) |
|
||||
| POST | `/api/insta/news/collect` | 뉴스 수집 트리거 (BackgroundTask) |
|
||||
| GET | `/api/insta/news/articles` | 수집 기사 목록 (category, days) |
|
||||
| POST | `/api/insta/keywords/extract` | 키워드 추출 트리거 (BackgroundTask) |
|
||||
| GET | `/api/insta/keywords` | 트렌딩 키워드 목록 (category, used) |
|
||||
| POST | `/api/insta/slates` | 슬레이트 생성 (keyword, category) |
|
||||
| GET | `/api/insta/slates` | 슬레이트 목록 |
|
||||
| GET | `/api/insta/slates/{id}` | 슬레이트 상세 + 자산 |
|
||||
| POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 |
|
||||
| GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) |
|
||||
| DELETE | `/api/insta/slates/{id}` | 슬레이트 삭제 (자산 파일 포함) |
|
||||
| GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 |
|
||||
| GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 CRUD |
|
||||
|
||||
### agent-office (agent-office/)
|
||||
- AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행
|
||||
- stock-lab/music-lab/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- stock/music-lab/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`)
|
||||
- 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드)
|
||||
- 청약 매칭 알림: realestate-lab이 신규 매칭 발견 시 push → `RealestateAgent.on_new_matches()` → 텔레그램 1통(인라인 [🔖 북마크]/[📄 공고] 또는 [전체 보기] 버튼)
|
||||
@@ -522,7 +524,7 @@ docker compose up -d
|
||||
**에이전트 FSM 상태**: idle → working → waiting (승인 대기) → reporting → break (휴식)
|
||||
|
||||
**환경변수**
|
||||
- `STOCK_LAB_URL`: stock-lab 내부 URL (기본 `http://stock-lab:8000`)
|
||||
- `STOCK_URL`: stock 내부 URL (기본 `http://stock:8000`)
|
||||
- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
|
||||
- `REALESTATE_LAB_URL`: realestate-lab 내부 URL (기본 `http://realestate-lab:8000`) — 북마크 콜백 프록시 대상
|
||||
- `REALESTATE_DASHBOARD_URL`: 텔레그램 [전체 보기] 버튼 URL (기본 `http://localhost:8080/realestate`)
|
||||
@@ -697,7 +699,8 @@ docker compose up -d
|
||||
- **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable)
|
||||
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
|
||||
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||
- **공휴일 목록**: `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
||||
- **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
||||
- **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용
|
||||
- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`)
|
||||
- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력
|
||||
- **insta-lab Playwright**: NAS에서 chromium 빌드는 가능하지만 +500MB 이미지. 메모리 부족 시 카드 렌더 실패 가능 — 한 번에 1슬레이트만 렌더하도록 직렬화됨
|
||||
|
||||
142
README.md
@@ -1,7 +1,7 @@
|
||||
# web-backend
|
||||
|
||||
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) │
|
||||
│ └── API 리버스 프록시 │
|
||||
│ ├── /api/ → lotto-backend:8000 (로또·블로그·투두)│
|
||||
│ ├── /api/stock/, /trade/ → stock-lab:8000 │
|
||||
│ ├── /api/portfolio → stock-lab:8000 │
|
||||
│ ├── /api/music/ → music-lab:8000 │
|
||||
│ ├── /api/blog-marketing/ → blog-lab:8000 │
|
||||
│ ├── /api/realestate/ → realestate-lab:8000 │
|
||||
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
||||
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||
│ ├── /media/music/… (nginx 직접 서빙, 생성 오디오) │
|
||||
│ ├── /api/ → lotto:8000 (로또) │
|
||||
│ ├── /api/stock/, /trade/ → stock:8000 │
|
||||
│ ├── /api/portfolio → stock:8000 │
|
||||
│ ├── /api/music/ → music-lab:8000 │
|
||||
│ ├── /api/insta/ → insta-lab:8000 │
|
||||
│ ├── /api/realestate/ → realestate-lab:8000 │
|
||||
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
||||
│ ├── /api/profile/, /todos, /blog/ → personal:8000 │
|
||||
│ ├── /api/packs/ → packs-lab:8000 (HMAC + 5GB upload) │
|
||||
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||
│ ├── /media/music/, /media/videos/ (nginx 직접 서빙, 미디어) │
|
||||
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
|
||||
│ └── /webhook → deployer:9000 │
|
||||
│ └── /webhook → deployer:9000 │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API |
|
||||
| `stock-lab` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
|
||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
|
||||
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
|
||||
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) + YouTube 수익화 |
|
||||
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드, Playwright) |
|
||||
| `realestate-lab` | 18800 | 청약 공고 자동 수집·5티어 매칭·신규 매칭 push |
|
||||
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
|
||||
| `personal` | 18850 | 개인 서비스 — 포트폴리오·블로그·투두 통합 |
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 청크 업로드) |
|
||||
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
|
||||
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||
| `frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
||||
|
||||
---
|
||||
@@ -44,12 +48,14 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
|
||||
```
|
||||
web-backend/
|
||||
├── backend/ # lotto-backend (로또·블로그·투두)
|
||||
├── stock-lab/ # 주식·포트폴리오
|
||||
├── music-lab/ # AI 음악 생성
|
||||
├── blog-lab/ # 블로그 마케팅 파이프라인
|
||||
├── realestate-lab/ # 청약 자동 수집·매칭
|
||||
├── lotto/ # 로또 추천·통계·시뮬레이션
|
||||
├── stock/ # 주식·포트폴리오·KIS 연동
|
||||
├── music-lab/ # AI 음악 생성 + YouTube 수익화
|
||||
├── insta-lab/ # 인스타 카드 피드 자동 생성 (Playwright)
|
||||
├── realestate-lab/ # 청약 자동 수집·5티어 매칭
|
||||
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
||||
├── personal/ # 포트폴리오·블로그·투두 통합
|
||||
├── packs-lab/ # NAS 자료 다운로드 자동화 (HMAC + Supabase)
|
||||
├── travel-proxy/ # 여행 사진 + 썸네일
|
||||
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
||||
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
||||
@@ -74,12 +80,14 @@ curl http://localhost:18500/health
|
||||
| 서비스 | 로컬 URL |
|
||||
|--------|----------|
|
||||
| Frontend + API | http://localhost:8080 |
|
||||
| lotto-backend | http://localhost:18000 |
|
||||
| stock-lab | http://localhost:18500 |
|
||||
| lotto | http://localhost:18000 |
|
||||
| stock | http://localhost:18500 |
|
||||
| music-lab | http://localhost:18600 |
|
||||
| blog-lab | http://localhost:18700 |
|
||||
| insta-lab | http://localhost:18700 |
|
||||
| realestate-lab | http://localhost:18800 |
|
||||
| personal | http://localhost:18850 |
|
||||
| agent-office | http://localhost:18900 |
|
||||
| packs-lab | http://localhost:18950 |
|
||||
| travel-proxy | http://localhost:19000 |
|
||||
|
||||
---
|
||||
@@ -99,7 +107,7 @@ curl http://localhost:18500/health
|
||||
- 09:10 / 21:10 — 당첨번호 동기화 + 추천 채점
|
||||
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (후보 20,000 → 상위 100 → best_picks 20쌍 교체)
|
||||
|
||||
### 2. stock-lab (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||
### 2. stock (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||
|
||||
주식 뉴스 스크래핑 + LLM 요약 + KIS 실계좌 연동 + 포트폴리오·자산 스냅샷.
|
||||
|
||||
@@ -123,20 +131,23 @@ curl http://localhost:18500/health
|
||||
- **라이브러리**: 생성 파일은 `/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 + 상위 블로그 본문 크롤링)
|
||||
→ 작가(AI 초안 생성)
|
||||
→ 마케터(전환율 강화 + 브랜드 링크 삽입)
|
||||
→ 평가자(6기준×10점, 42/60 통과 시 published)
|
||||
NAVER 뉴스 + YouTube 인기 (외부 트렌드)
|
||||
→ 카테고리별 빈도 + Claude Haiku 정제 → 트렌딩 키워드
|
||||
→ 사용자가 키워드 선택
|
||||
→ Claude Sonnet으로 10페이지 카피 추론 (커버 1 + 본문 8 + CTA 1)
|
||||
→ Jinja2 + Playwright 1080×1350 PNG 10장 렌더
|
||||
→ 텔레그램 미디어 그룹 + 추천 캡션·해시태그
|
||||
```
|
||||
|
||||
- **AI 엔진**: Claude API (`claude-sonnet-4-20250514`)
|
||||
- **키워드 분석**: 네이버 검색(블로그+쇼핑) API + 경쟁도/기회 점수
|
||||
- **수익 추적**: 포스트별 월간 클릭/구매/수익 기록
|
||||
- **AI 엔진**: Claude Sonnet (카피) + Claude Haiku (키워드 분류)
|
||||
- **데이터 소스**: NAVER 뉴스 검색 + YouTube Data API v3 mostPopular(KR)
|
||||
- **카테고리 가중치**: 사용자가 economy/psychology/celebrity 등 카테고리별 가중치 설정 → 자동 추출 비율에 반영
|
||||
- **카드 디자인**: `insta-lab/app/templates/default/card.html.j2` — 사용자가 자유 수정 (Tailwind 등)
|
||||
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
|
||||
|
||||
### 5. realestate-lab (`/api/realestate/`)
|
||||
@@ -152,7 +163,7 @@ curl http://localhost:18500/health
|
||||
|
||||
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
||||
|
||||
- **아키텍처**: stock-lab / music-lab / blog-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- **아키텍처**: stock / music-lab / insta-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
|
||||
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
|
||||
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||
@@ -165,22 +176,28 @@ AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에
|
||||
|---------|--------|-----|----------|
|
||||
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
||||
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
||||
| ✍️ **블로그 마케터** (`blog`) | 10:00 매일 | ✅ 발행 | 트렌드 키워드 1개 선택 → 리서치→작가→마케터→평가 자동 실행 → 점수·본문을 텔레그램 승인 요청 → 승인 시 `published` 전환, 거절 시 재생성 |
|
||||
| 🏢 **청약 애널리스트** (`realestate`) | 09:15 매일 | — | realestate-lab 수집 트리거 → 신규 매칭 상위 5건 + 대시보드 요약을 텔레그램 리포트 (읽음 처리 자동) |
|
||||
| 🎴 **인스타 큐레이터** (`insta`) | 09:00 / 09:30 매일 | — | 09:00 외부 트렌드(NAVER + YouTube) 수집 → 09:30 가중치 기반 키워드 추출 → 텔레그램 후보 5개씩 카테고리당 인라인 버튼 푸시 → 사용자 선택 시 카드 10장 미디어 그룹 |
|
||||
| 🏢 **청약 애널리스트** (`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`
|
||||
**Music** — `compose` (승인 필요), `credits`
|
||||
**Blog** — `research {keyword}`, `add_trend_keyword`, `list_trend_keywords`
|
||||
**Insta** — `extract`, `render <keyword_id>`, `collect_trends`
|
||||
**Realestate** — `fetch_matches`, `dashboard`
|
||||
**YouTube** — `research {countries: [...]}`
|
||||
|
||||
#### 스케줄러 잡
|
||||
|
||||
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
||||
- 07:30 — Stock: 뉴스 요약
|
||||
- 09:15 — Realestate: 매칭 리포트
|
||||
- 10:00 — Blog: 자동 파이프라인 (리서치→생성→리뷰→승인 대기)
|
||||
- 08:00 평일 — Stock: AI 뉴스 sentiment 분석
|
||||
- 09:00 — YouTube: 한국 트렌딩 수집
|
||||
- 09:00 — Insta: 외부 트렌드 수집 (NAVER 인기 + YouTube mostPopular)
|
||||
- 09:30 — Insta: 키워드 추출 (가중치 적용) + 텔레그램 후보 푸시
|
||||
- 15:40 평일 — Stock: 총 자산 스냅샷
|
||||
- 16:30 평일 — Stock: 스크리너 실행
|
||||
- 60초 interval — 유휴 에이전트 휴식 체크
|
||||
|
||||
### 7. travel-proxy (`/api/travel/`)
|
||||
@@ -224,7 +241,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
|
||||
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
||||
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
||||
|
||||
### LLM 요약 provider 추상화 (stock-lab)
|
||||
### LLM 요약 provider 추상화 (stock)
|
||||
|
||||
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
|
||||
|
||||
@@ -232,7 +249,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
|
||||
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
|
||||
- 실패 시 `LLMError` (구 `OllamaError` alias 유지)
|
||||
|
||||
### 총 자산 스냅샷 (stock-lab)
|
||||
### 총 자산 스냅샷 (stock)
|
||||
|
||||
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
|
||||
|
||||
@@ -265,13 +282,15 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
||||
|
||||
| DB | 소유 서비스 | 주요 테이블 |
|
||||
|----|------------|-----------|
|
||||
| `lotto.db` | lotto-backend | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings, todos, blog_posts |
|
||||
| `stock.db` | stock-lab | 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) |
|
||||
| `blog_marketing.db` | blog-lab | keyword_analyses, blog_posts, brand_links, commissions, generation_tasks, prompt_templates |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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
|
||||
WEBHOOK_SECRET=your_secret_here
|
||||
|
||||
# LLM (stock-lab, blog-lab, agent-office 공통)
|
||||
# LLM (stock, insta-lab, agent-office 공통)
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||
LLM_PROVIDER=claude # claude | ollama
|
||||
OLLAMA_URL=http://192.168.45.59:11435
|
||||
OLLAMA_MODEL=qwen3:14b
|
||||
|
||||
# stock admin protection (CODE_REVIEW F2)
|
||||
ADMIN_API_KEY=
|
||||
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||
|
||||
# music-lab
|
||||
SUNO_API_KEY=
|
||||
MUSIC_AI_SERVER_URL=
|
||||
MUSIC_MEDIA_BASE=/media/music
|
||||
|
||||
# blog-lab
|
||||
# insta-lab + agent-office (NAVER 검색 + YouTube Data API 공유)
|
||||
NAVER_CLIENT_ID=
|
||||
NAVER_CLIENT_SECRET=
|
||||
YOUTUBE_DATA_API_KEY=
|
||||
|
||||
# realestate-lab
|
||||
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
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
TELEGRAM_WEBHOOK_URL=
|
||||
STOCK_LAB_URL=http://stock-lab:8000
|
||||
STOCK_URL=http://stock: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
|
||||
|
||||
# personal (포트폴리오 편집 인증)
|
||||
PORTFOLIO_EDIT_PASSWORD=
|
||||
```
|
||||
|
||||
---
|
||||
@@ -343,7 +379,7 @@ REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||
- **라우트 순서** — `DELETE /api/todos/done`은 `/api/todos/{id}` 보다 먼저 등록 필수 (FastAPI prefix 매칭)
|
||||
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
|
||||
- **PUID/PGID** — travel-proxy는 NAS 파일 권한을 위해 환경변수 주입 필수
|
||||
- **공휴일 목록** — `stock-lab/app/holidays.json` 매년 수동 갱신 (KRX 기준)
|
||||
- **공휴일 목록** — `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준)
|
||||
- **Windows AI 서버 IP** — `192.168.45.59` 공유기 DHCP 고정 예약. Synology Tailscale은 userspace 모드라 TCP 불가 → 로컬 IP 사용
|
||||
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
|
||||
- **LLM provider 롤백** — Claude API 장애 시 `.env`의 `LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`
|
||||
|
||||
34
STATUS.md
@@ -1,40 +1,42 @@
|
||||
# web-backend — 구현 현황 & 로드맵
|
||||
|
||||
> 최종 갱신: 2026-05-07
|
||||
> 최종 갱신: 2026-05-17
|
||||
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 서비스 구현 현황
|
||||
|
||||
### 1-1. 운영 중인 컨테이너 (10개)
|
||||
### 1-1. 운영 중인 컨테이너 (11개)
|
||||
|
||||
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
||||
|--------|------|------|-----------|
|
||||
| `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 |
|
||||
| `stock-lab` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
|
||||
| `lotto` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역·AI 큐레이터 |
|
||||
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷·스크리너 |
|
||||
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
||||
| `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 |
|
||||
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 |
|
||||
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + YouTubeResearcher) |
|
||||
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase) — 2026-05-05 |
|
||||
| `insta-lab` | 18700 | ✅ | 인스타 카드 피드 자동 생성 (NAVER + YouTube 트렌드 → 10페이지 카드, Playwright) |
|
||||
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 push |
|
||||
| `personal` | 18850 | ✅ | 포트폴리오·블로그·투두 통합 (개인 서비스) |
|
||||
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + InstaAgent + YouTubeResearcher) |
|
||||
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase + 5GB chunked upload) |
|
||||
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
|
||||
| `nginx` | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit) |
|
||||
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 |
|
||||
| `frontend` (nginx) | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit, 인스타 라우팅 포함) |
|
||||
| `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-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 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
|
||||
| 2026-04-24 | travel-proxy | 갤러리 리디자인 + 성능 개선 (썸네일/페이지네이션) |
|
||||
| 2026-04-15 | lotto-backend | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||
| 2026-04-08 | music-lab | Suno enhancement + MusicGen 통합 |
|
||||
| 2026-04-06 | blog-lab | 마케팅 파이프라인 (research → generate → market → review) |
|
||||
| 2026-04-15 | lotto | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||
|
||||
### 1-3. 인프라 / DX
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from .stock import StockAgent
|
||||
from .music import MusicAgent
|
||||
from .blog import BlogAgent
|
||||
from .insta import InstaAgent
|
||||
from .realestate import RealestateAgent
|
||||
from .lotto import LottoAgent
|
||||
from .youtube import YouTubeResearchAgent
|
||||
@@ -11,7 +11,7 @@ AGENT_REGISTRY = {}
|
||||
def init_agents():
|
||||
AGENT_REGISTRY["stock"] = StockAgent()
|
||||
AGENT_REGISTRY["music"] = MusicAgent()
|
||||
AGENT_REGISTRY["blog"] = BlogAgent()
|
||||
AGENT_REGISTRY["insta"] = InstaAgent()
|
||||
AGENT_REGISTRY["realestate"] = RealestateAgent()
|
||||
AGENT_REGISTRY["lotto"] = LottoAgent()
|
||||
AGENT_REGISTRY["youtube"] = YouTubeResearchAgent()
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from .base import BaseAgent
|
||||
from ..db import (
|
||||
create_task, update_task_status, approve_task, reject_task,
|
||||
get_task, get_agent_config, add_log,
|
||||
)
|
||||
from .. import service_proxy
|
||||
from .. import telegram_bot
|
||||
|
||||
|
||||
DEFAULT_TREND_KEYWORDS = [
|
||||
"다이어트 식단", "재택근무 꿀템", "캠핑 장비 추천",
|
||||
"홈트레이닝", "제주도 여행", "에어프라이어 레시피",
|
||||
]
|
||||
|
||||
|
||||
class BlogAgent(BaseAgent):
|
||||
"""블로그 마케팅 에이전트.
|
||||
|
||||
매일 10:00 자동 실행: 키워드 1개 리서치 → 글 생성 → 마케터 → 평가자
|
||||
→ 평가 점수와 요약을 텔레그램 승인 요청으로 푸시
|
||||
→ 승인 시 `published` 상태로 전환, 거절 시 재생성
|
||||
"""
|
||||
|
||||
agent_id = "blog"
|
||||
display_name = "블로그 마케터"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
keywords = custom.get("trend_keywords") or DEFAULT_TREND_KEYWORDS
|
||||
if not keywords:
|
||||
return
|
||||
|
||||
import random
|
||||
keyword = random.choice(keywords)
|
||||
|
||||
task_id = create_task(
|
||||
self.agent_id,
|
||||
"auto_blog_pipeline",
|
||||
{"keyword": keyword},
|
||||
requires_approval=True,
|
||||
)
|
||||
await self.transition("working", f"리서치: {keyword}", task_id)
|
||||
asyncio.create_task(self._run_pipeline(task_id, keyword))
|
||||
|
||||
async def _await_task(self, step: str, task_id: str, timeout_sec: int = 240) -> Optional[int]:
|
||||
"""blog-lab BackgroundTask 완료 폴링. 완료 시 result_id 반환."""
|
||||
attempts = max(1, timeout_sec // 5)
|
||||
for _ in range(attempts):
|
||||
await asyncio.sleep(5)
|
||||
status = await service_proxy.blog_task_status(task_id)
|
||||
s = status.get("status")
|
||||
if s == "succeeded":
|
||||
return status.get("result_id")
|
||||
if s == "failed":
|
||||
raise Exception(f"{step} failed: {status.get('error')}")
|
||||
raise Exception(f"{step} timeout ({timeout_sec}s 내 완료되지 않음)")
|
||||
|
||||
async def _run_pipeline(self, task_id: str, keyword: str) -> None:
|
||||
try:
|
||||
# 1) 리서치
|
||||
research = await service_proxy.blog_research(keyword)
|
||||
keyword_id = await self._await_task("research", research.get("task_id"), 180)
|
||||
if not keyword_id:
|
||||
raise Exception("research succeeded but result_id missing")
|
||||
|
||||
# 2) 작가 단계 (비동기)
|
||||
await self.transition("working", f"글 생성: {keyword}", task_id)
|
||||
gen = await service_proxy.blog_generate(keyword_id)
|
||||
post_id = await self._await_task("generate", gen.get("task_id"), 300)
|
||||
if not post_id:
|
||||
raise Exception("generate succeeded but post_id missing")
|
||||
|
||||
# 3) 마케터 단계 (비동기)
|
||||
await self.transition("working", "링크 삽입 중", task_id)
|
||||
mkt = await service_proxy.blog_market(post_id)
|
||||
await self._await_task("market", mkt.get("task_id"), 180)
|
||||
|
||||
# 4) 평가자 단계 (비동기)
|
||||
await self.transition("working", "품질 리뷰 중", task_id)
|
||||
rev = await service_proxy.blog_review(post_id)
|
||||
await self._await_task("review", rev.get("task_id"), 180)
|
||||
|
||||
post_after = await service_proxy.blog_get_post(post_id)
|
||||
score = post_after.get("review_score")
|
||||
passed = (score or 0) >= 42
|
||||
|
||||
title = post_after.get("title", "(제목 없음)")
|
||||
excerpt = (post_after.get("body") or "")[:300]
|
||||
|
||||
update_task_status(task_id, "pending", {
|
||||
"keyword": keyword,
|
||||
"post_id": post_id,
|
||||
"score": score,
|
||||
"passed": passed,
|
||||
"title": title,
|
||||
})
|
||||
|
||||
await self.transition("waiting", f"승인 대기 · {score}/60", task_id)
|
||||
|
||||
detail = (
|
||||
f"키워드: {keyword}\n"
|
||||
f"제목: {title}\n"
|
||||
f"평가 점수: {score}/60 ({'통과' if passed else '미통과'})\n\n"
|
||||
f"{excerpt}..."
|
||||
)
|
||||
await telegram_bot.send_approval_request(
|
||||
self.agent_id, task_id,
|
||||
"✍️ [블로그 에이전트] 발행 승인 요청", detail,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
add_log(self.agent_id, f"Blog pipeline failed: {e}", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": str(e), "keyword": keyword})
|
||||
await self.transition("idle", f"오류: {e}")
|
||||
await telegram_bot.send_task_result(
|
||||
self.agent_id, "✍️ [블로그 에이전트] 파이프라인 실패",
|
||||
f"키워드: {keyword}\n오류: {e}",
|
||||
)
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "research":
|
||||
keyword = (params.get("keyword") or "").strip()
|
||||
if not keyword:
|
||||
return {"ok": False, "message": "keyword 필수"}
|
||||
task_id = create_task(
|
||||
self.agent_id, "auto_blog_pipeline",
|
||||
{"keyword": keyword}, requires_approval=True,
|
||||
)
|
||||
await self.transition("working", f"리서치: {keyword}", task_id)
|
||||
asyncio.create_task(self._run_pipeline(task_id, keyword))
|
||||
return {"ok": True, "task_id": task_id, "message": f"파이프라인 시작: {keyword}"}
|
||||
|
||||
if command == "add_trend_keyword":
|
||||
keyword = (params.get("keyword") or "").strip()
|
||||
if not keyword:
|
||||
return {"ok": False, "message": "keyword 필수"}
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
kws = list(custom.get("trend_keywords") or [])
|
||||
if keyword not in kws:
|
||||
kws.append(keyword)
|
||||
from ..db import update_agent_config
|
||||
update_agent_config(self.agent_id, custom_config={**custom, "trend_keywords": kws})
|
||||
return {"ok": True, "keywords": kws}
|
||||
|
||||
if command == "list_trend_keywords":
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
return {"ok": True, "keywords": custom.get("trend_keywords") or DEFAULT_TREND_KEYWORDS}
|
||||
|
||||
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||
|
||||
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||
task = get_task(task_id)
|
||||
if not task:
|
||||
return
|
||||
result = task.get("result_data") or {}
|
||||
post_id = result.get("post_id")
|
||||
|
||||
if not approved:
|
||||
reject_task(task_id)
|
||||
await self.transition("idle", "발행 거절됨")
|
||||
await telegram_bot.send_task_result(
|
||||
self.agent_id, "✍️ [블로그 에이전트] 발행 취소",
|
||||
f"키워드: {result.get('keyword', '')}\n사용자가 거절했습니다.",
|
||||
)
|
||||
return
|
||||
|
||||
approve_task(task_id, via="telegram")
|
||||
await self.transition("reporting", "발행 중...", task_id)
|
||||
|
||||
try:
|
||||
if post_id:
|
||||
await service_proxy.blog_publish(int(post_id))
|
||||
update_task_status(task_id, "succeeded", {**result, "published": True})
|
||||
await telegram_bot.send_task_result(
|
||||
self.agent_id, "✍️ [블로그 에이전트] 발행 완료",
|
||||
f"키워드: {result.get('keyword', '')}\n제목: {result.get('title', '')}\n"
|
||||
f"점수: {result.get('score')}/60",
|
||||
)
|
||||
await self.transition("idle", "발행 완료")
|
||||
except Exception as e:
|
||||
add_log(self.agent_id, f"Blog publish failed: {e}", "error", task_id)
|
||||
update_task_status(task_id, "failed", {**result, "publish_error": str(e)})
|
||||
await self.transition("idle", f"발행 오류: {e}")
|
||||
170
agent-office/app/agents/insta.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""인스타 카드 에이전트 — 매일 09:30 뉴스 수집·키워드 추출 → 텔레그램 후보 푸시.
|
||||
사용자가 키워드 버튼을 누르면 카드 슬레이트 생성 + 10장 미디어 그룹 발송."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import BaseAgent
|
||||
from ..db import (
|
||||
create_task, update_task_status, add_log, get_agent_config,
|
||||
)
|
||||
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
|
||||
from .. import service_proxy
|
||||
from ..telegram import messaging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _send_media_group(media: List[Dict[str, Any]], caption: str = "") -> Dict[str, Any]:
|
||||
"""텔레그램 sendMediaGroup. media는 InputMediaPhoto dicts.
|
||||
각 항목에는 임시 키 '_bytes'로 PNG 바이트가 담겨 있어 attach:// 형식으로 multipart 업로드."""
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
return {"ok": False, "reason": "TELEGRAM_BOT_TOKEN missing"}
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMediaGroup"
|
||||
files: Dict[str, tuple] = {}
|
||||
for i, m in enumerate(media):
|
||||
attach_key = f"photo{i+1}"
|
||||
files[attach_key] = (f"{i+1}.png", m["_bytes"], "image/png")
|
||||
m["media"] = f"attach://{attach_key}"
|
||||
m.pop("_bytes", None)
|
||||
if caption and media:
|
||||
media[0]["caption"] = caption[:1024]
|
||||
payload = {"chat_id": TELEGRAM_CHAT_ID, "media": json.dumps(media, ensure_ascii=False)}
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(url, data=payload, files=files)
|
||||
return resp.json()
|
||||
|
||||
|
||||
class InstaAgent(BaseAgent):
|
||||
agent_id = "insta"
|
||||
display_name = "인스타 큐레이터"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
"""09:30 매일: 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시.
|
||||
custom_config.auto_select=True면 카테고리당 1위 키워드 자동 슬레이트 생성."""
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
auto_select = bool(custom.get("auto_select", False))
|
||||
|
||||
task_id = create_task(self.agent_id, "insta_daily", {"auto_select": auto_select},
|
||||
requires_approval=False)
|
||||
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
|
||||
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()
|
||||
kws = await service_proxy.insta_list_keywords(used=False)
|
||||
if auto_select:
|
||||
await self._auto_render(kws)
|
||||
else:
|
||||
await self._push_keyword_candidates(kws)
|
||||
update_task_status(task_id, "succeeded", {"keywords": len(kws)})
|
||||
await self.transition("idle", "후보 푸시 완료")
|
||||
except Exception as e:
|
||||
add_log(self.agent_id, f"insta daily failed: {e}", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": str(e)})
|
||||
await self.transition("idle", f"오류: {e}")
|
||||
|
||||
async def _run_collect_and_extract(self) -> None:
|
||||
col = await service_proxy.insta_collect()
|
||||
await self._wait_task(col["task_id"], step="collect", timeout_sec=300)
|
||||
ext = await service_proxy.insta_extract()
|
||||
await self._wait_task(ext["task_id"], step="extract", timeout_sec=300)
|
||||
|
||||
async def _wait_task(self, task_id: str, step: str, timeout_sec: int = 300) -> Dict[str, Any]:
|
||||
attempts = max(1, timeout_sec // 5)
|
||||
for _ in range(attempts):
|
||||
await asyncio.sleep(5)
|
||||
st = await service_proxy.insta_task_status(task_id)
|
||||
if st["status"] == "succeeded":
|
||||
return st
|
||||
if st["status"] == "failed":
|
||||
raise RuntimeError(f"{step} failed: {st.get('error')}")
|
||||
raise TimeoutError(f"{step} timeout {timeout_sec}s")
|
||||
|
||||
async def _push_keyword_candidates(self, keywords: List[Dict[str, Any]]) -> None:
|
||||
by_cat: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for k in keywords:
|
||||
by_cat.setdefault(k["category"], []).append(k)
|
||||
if not by_cat:
|
||||
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 추천할 키워드가 없습니다.")
|
||||
return
|
||||
rows: List[List[Dict[str, Any]]] = []
|
||||
text_lines = ["📰 <b>[인스타 큐레이터]</b> 오늘의 키워드 후보"]
|
||||
for cat, items in by_cat.items():
|
||||
text_lines.append(f"\n<b>{cat}</b>")
|
||||
for k in items[:5]:
|
||||
text_lines.append(f" · {k['keyword']} (score {k['score']:.2f})")
|
||||
rows.append([{
|
||||
"text": f"🎴 {k['keyword']}",
|
||||
"callback_data": f"render_{k['id']}",
|
||||
}])
|
||||
await messaging.send_raw("\n".join(text_lines), reply_markup={"inline_keyboard": rows})
|
||||
|
||||
async def _auto_render(self, keywords: List[Dict[str, Any]]) -> None:
|
||||
by_cat: Dict[str, Dict[str, Any]] = {}
|
||||
for k in keywords:
|
||||
cat = k["category"]
|
||||
if cat not in by_cat or k["score"] > by_cat[cat]["score"]:
|
||||
by_cat[cat] = k
|
||||
for kw in by_cat.values():
|
||||
await self._render_and_push(kw["id"])
|
||||
|
||||
async def _render_and_push(self, keyword_id: int) -> None:
|
||||
kw = await service_proxy.insta_get_keyword(keyword_id)
|
||||
if not kw:
|
||||
await messaging.send_raw(f"⚠️ 키워드 {keyword_id} 없음")
|
||||
return
|
||||
await messaging.send_raw(f"🎨 카드 생성 중: <b>{kw['keyword']}</b>")
|
||||
created = await service_proxy.insta_create_slate(
|
||||
keyword=kw["keyword"], category=kw["category"], keyword_id=kw["id"],
|
||||
)
|
||||
st = await self._wait_task(created["task_id"], step="slate", timeout_sec=600)
|
||||
slate_id = st["result_id"]
|
||||
slate = await service_proxy.insta_get_slate(slate_id)
|
||||
media = []
|
||||
for a in slate["assets"][:10]:
|
||||
data = await service_proxy.insta_get_asset_bytes(slate_id, a["page_index"])
|
||||
media.append({"type": "photo", "_bytes": data})
|
||||
caption = slate.get("suggested_caption", "")
|
||||
hashtags = " ".join(slate.get("hashtags", []) or [])
|
||||
full_caption = f"{caption}\n\n{hashtags}".strip()
|
||||
await _send_media_group(media, caption=full_caption)
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "extract":
|
||||
await self._run_collect_and_extract()
|
||||
kws = await service_proxy.insta_list_keywords(used=False)
|
||||
await self._push_keyword_candidates(kws)
|
||||
return {"ok": True, "count": len(kws)}
|
||||
if command == "render":
|
||||
kid = int(params.get("keyword_id") or 0)
|
||||
if not kid:
|
||||
return {"ok": False, "message": "keyword_id 필수"}
|
||||
await self._render_and_push(kid)
|
||||
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}"}
|
||||
|
||||
async def on_callback(self, action: str, params: dict) -> dict:
|
||||
if action == "render":
|
||||
kid = int(params.get("keyword_id") or 0)
|
||||
if not kid:
|
||||
return {"ok": False}
|
||||
await self._render_and_push(kid)
|
||||
return {"ok": True}
|
||||
return {"ok": False}
|
||||
|
||||
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||
return
|
||||
@@ -51,7 +51,7 @@ class StockAgent(BaseAgent):
|
||||
await self.transition("working", "최신 뉴스 수집 중...", task_id)
|
||||
|
||||
try:
|
||||
# stock-lab cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
|
||||
# stock cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
|
||||
# 요약되던 문제 방지 — 요약 직전에 동기 스크랩으로 DB를 갱신한다.
|
||||
try:
|
||||
await service_proxy.scrape_stock_news()
|
||||
@@ -60,7 +60,7 @@ class StockAgent(BaseAgent):
|
||||
|
||||
await self.transition("working", "AI 뉴스 요약 생성 중...")
|
||||
|
||||
# AI 요약 호출 (LLM 처리는 stock-lab이 담당)
|
||||
# AI 요약 호출 (LLM 처리는 stock이 담당)
|
||||
result = await service_proxy.summarize_stock_news(limit=15)
|
||||
|
||||
await self.transition("reporting", "뉴스 요약 전송 중...")
|
||||
@@ -233,11 +233,118 @@ class StockAgent(BaseAgent):
|
||||
|
||||
await self.transition("idle", f"스크리너 오류: {err_msg[:80]}")
|
||||
|
||||
async def on_ai_news_schedule(self) -> None:
|
||||
"""AI 뉴스 sentiment 분석 자동 잡 (평일 08:00 KST).
|
||||
|
||||
흐름:
|
||||
1) stock /snapshot/refresh-news-sentiment 호출
|
||||
2) status='skipped_weekend'/'skipped_holiday' → 종료 (텔레그램 미발신)
|
||||
3) updated=0 → 운영자 알림 (HTML)
|
||||
4) failures > 30% → 경고 알림 후 메인 메시지 발송
|
||||
5) 정상 → Top 5 호재/악재 메시지 발송 (MarkdownV2)
|
||||
"""
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "ai_news_sentiment", {})
|
||||
await self.transition("working", "AI 뉴스 분석 중...", task_id)
|
||||
|
||||
try:
|
||||
result = await service_proxy.refresh_ai_news_sentiment()
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
add_log(self.agent_id, f"AI 뉴스 분석 실패: {err_msg}", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": err_msg})
|
||||
try:
|
||||
from ..telegram.messaging import send_raw
|
||||
await send_raw(
|
||||
f"⚠️ <b>AI 뉴스 분석 실패</b>\n"
|
||||
f"<code>{html.escape(err_msg)[:500]}</code>"
|
||||
)
|
||||
except Exception as notify_err:
|
||||
add_log(
|
||||
self.agent_id,
|
||||
f"operator notify failed: {notify_err}",
|
||||
"warning", task_id,
|
||||
)
|
||||
await self.transition("idle", f"AI 뉴스 오류: {err_msg[:80]}")
|
||||
return
|
||||
|
||||
status = result.get("status")
|
||||
if status in ("skipped_weekend", "skipped_holiday"):
|
||||
update_task_status(task_id, "succeeded", {"status": status})
|
||||
add_log(self.agent_id, f"AI 뉴스 건너뜀: {status}", "info", task_id)
|
||||
await self.transition("idle", "휴일/주말 — 건너뜀")
|
||||
return
|
||||
|
||||
updated = int(result.get("updated", 0))
|
||||
failures = result.get("failures", []) or []
|
||||
if updated == 0:
|
||||
update_task_status(task_id, "failed", {"reason": "0 tickers updated"})
|
||||
try:
|
||||
from ..telegram.messaging import send_raw
|
||||
await send_raw(
|
||||
"⚠️ <b>AI 뉴스 분석 0종목</b>\n"
|
||||
"스크래핑/LLM 전체 실패 — 어제 데이터 사용"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await self.transition("idle", "AI 뉴스 0건")
|
||||
return
|
||||
|
||||
# 실패율 경고 (별도 알림, 본 메시지는 계속 발송)
|
||||
failure_rate = len(failures) / max(1, updated + len(failures))
|
||||
if failure_rate > 0.3:
|
||||
try:
|
||||
from ..telegram.messaging import send_raw
|
||||
await send_raw(
|
||||
f"⚠️ <b>AI 뉴스 실패율 {failure_rate:.0%}</b>\n"
|
||||
f"updated={updated}, failures={len(failures)}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 정상 — Top 5 메시지 (stock이 빌드해서 응답에 telegram_text 동봉)
|
||||
text = result.get("telegram_text") or ""
|
||||
if not text:
|
||||
add_log(self.agent_id, "telegram_text 누락 — stock 응답 결함", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": "telegram_text 누락"})
|
||||
await self.transition("idle", "AI 뉴스 응답 결함")
|
||||
return
|
||||
|
||||
await self.transition("reporting", "AI 뉴스 알림 전송 중...")
|
||||
from ..telegram.messaging import send_raw
|
||||
tg = await send_raw(text, parse_mode="MarkdownV2")
|
||||
|
||||
update_task_status(task_id, "succeeded", {
|
||||
"asof": result["asof"],
|
||||
"updated": updated,
|
||||
"failures": len(failures),
|
||||
"tokens_input": int(result.get("tokens_input", 0)),
|
||||
"tokens_output": int(result.get("tokens_output", 0)),
|
||||
"telegram_sent": tg.get("ok", False),
|
||||
})
|
||||
|
||||
if not tg.get("ok"):
|
||||
desc = tg.get("description") or "unknown"
|
||||
code = tg.get("error_code")
|
||||
add_log(
|
||||
self.agent_id,
|
||||
f"AI news telegram send failed: [{code}] {desc}",
|
||||
"warning", task_id,
|
||||
)
|
||||
|
||||
await self.transition("idle", "AI 뉴스 완료")
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "run_screener":
|
||||
await self.on_screener_schedule()
|
||||
return {"ok": True, "message": "스크리너 실행 트리거 완료"}
|
||||
|
||||
if command == "run_ai_news":
|
||||
await self.on_ai_news_schedule()
|
||||
return {"ok": True, "message": "AI 뉴스 분석 트리거 완료"}
|
||||
|
||||
if command == "test_telegram":
|
||||
from ..telegram import send_agent_message
|
||||
result = await send_agent_message(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import os
|
||||
|
||||
# Service URLs (Docker internal network)
|
||||
STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://localhost:18500")
|
||||
STOCK_URL = os.getenv("STOCK_URL", "http://localhost:18500")
|
||||
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")
|
||||
BLOG_LAB_URL = os.getenv("BLOG_LAB_URL", "http://localhost:18700")
|
||||
INSTA_LAB_URL = os.getenv("INSTA_LAB_URL", "http://localhost:18700")
|
||||
REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://localhost:18800")
|
||||
|
||||
# Telegram
|
||||
|
||||
@@ -9,9 +9,10 @@ from .config import DB_PATH
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
|
||||
|
||||
@@ -19,11 +19,22 @@ async def _run_stock_screener():
|
||||
if agent:
|
||||
await agent.on_screener_schedule()
|
||||
|
||||
async def _run_blog_schedule():
|
||||
agent = AGENT_REGISTRY.get("blog")
|
||||
async def _run_stock_ai_news():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
await agent.on_ai_news_schedule()
|
||||
|
||||
async def _run_insta_schedule():
|
||||
agent = AGENT_REGISTRY.get("insta")
|
||||
if agent:
|
||||
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():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
@@ -54,7 +65,16 @@ def init_scheduler():
|
||||
minute=30,
|
||||
id="stock_screener",
|
||||
)
|
||||
scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline")
|
||||
scheduler.add_job(
|
||||
_run_stock_ai_news,
|
||||
"cron",
|
||||
day_of_week="mon-fri",
|
||||
hour=8,
|
||||
minute=0,
|
||||
id="stock_ai_news_sentiment",
|
||||
)
|
||||
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_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")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import STOCK_LAB_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL
|
||||
from .config import STOCK_URL, MUSIC_LAB_URL, INSTA_LAB_URL, REALESTATE_LAB_URL
|
||||
|
||||
_client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
@@ -9,23 +9,23 @@ async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[s
|
||||
params = {"limit": limit}
|
||||
if category:
|
||||
params["category"] = category
|
||||
resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/news", params=params)
|
||||
resp = await _client.get(f"{STOCK_URL}/api/stock/news", params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def fetch_stock_indices() -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/indices")
|
||||
resp = await _client.get(f"{STOCK_URL}/api/stock/indices")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
|
||||
"""stock-lab의 AI 요약 엔드포인트 호출.
|
||||
"""stock의 AI 요약 엔드포인트 호출.
|
||||
반환: {"summary": str, "tokens": {...}, "model": str, "duration_ms": int, "article_count": int}
|
||||
"""
|
||||
# stock-lab 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
|
||||
# stock 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
|
||||
async with httpx.AsyncClient(timeout=200.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_LAB_URL}/api/stock/news/summarize",
|
||||
f"{STOCK_URL}/api/stock/news/summarize",
|
||||
json={"limit": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -33,18 +33,32 @@ async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
|
||||
|
||||
|
||||
async def refresh_screener_snapshot() -> Dict[str, Any]:
|
||||
"""stock-lab의 KRX 일봉 스냅샷 갱신 (스크리너 실행 전 호출).
|
||||
"""stock의 KRX 일봉 스냅샷 갱신 (스크리너 실행 전 호출).
|
||||
|
||||
네이버 금융 일괄 다운로드라 보통 30~120s, 여유있게 180s.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
resp = await client.post(f"{STOCK_LAB_URL}/api/stock/screener/snapshot/refresh")
|
||||
resp = await client.post(f"{STOCK_URL}/api/stock/screener/snapshot/refresh")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def refresh_ai_news_sentiment() -> Dict[str, Any]:
|
||||
"""stock의 AI 뉴스 sentiment 분석 트리거 (08:00 cron).
|
||||
|
||||
네이버 100종목 스크래핑 + Claude Haiku 100콜 병렬 = 약 30-60초.
|
||||
여유있게 240s timeout.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=240.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_URL}/api/stock/screener/snapshot/refresh-news-sentiment"
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
|
||||
"""stock-lab의 스크리너 실행.
|
||||
"""stock의 스크리너 실행.
|
||||
|
||||
반환 status:
|
||||
- 'skipped_holiday': 공휴일/주말 — telegram_payload 없음
|
||||
@@ -53,7 +67,7 @@ async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_LAB_URL}/api/stock/screener/run",
|
||||
f"{STOCK_URL}/api/stock/screener/run",
|
||||
json={"mode": mode},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -61,13 +75,13 @@ async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
|
||||
|
||||
|
||||
async def scrape_stock_news() -> Dict[str, Any]:
|
||||
"""stock-lab의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
|
||||
"""stock의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
|
||||
|
||||
아침 브리핑 직전 호출하여 어제 데이터가 아닌 오늘 새벽 뉴스를 보장한다.
|
||||
네이버 금융 단일 요청이라 보통 수 초 내 완료, 여유있게 60s.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(f"{STOCK_LAB_URL}/api/stock/scrap")
|
||||
resp = await client.post(f"{STOCK_URL}/api/stock/scrap")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
@@ -87,60 +101,107 @@ async def get_music_credits() -> Dict[str, Any]:
|
||||
return resp.json()
|
||||
|
||||
|
||||
# --- blog-lab ---
|
||||
# --- insta-lab ---
|
||||
|
||||
async def blog_research(keyword: str) -> Dict[str, Any]:
|
||||
"""키워드 리서치 시작 → task_id 반환"""
|
||||
async def insta_collect(categories: Optional[list] = None) -> Dict[str, Any]:
|
||||
"""뉴스 수집 트리거 → task_id 반환."""
|
||||
payload = {"categories": categories} if categories else {}
|
||||
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/news/collect", json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def insta_extract(categories: Optional[list] = None) -> Dict[str, Any]:
|
||||
payload = {"categories": categories} if categories else {}
|
||||
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/keywords/extract", json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def insta_list_keywords(category: Optional[str] = None,
|
||||
used: Optional[bool] = None) -> List[Dict[str, Any]]:
|
||||
params: Dict[str, Any] = {}
|
||||
if category:
|
||||
params["category"] = category
|
||||
if used is not None:
|
||||
params["used"] = "true" if used else "false"
|
||||
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/keywords", params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("items", [])
|
||||
|
||||
|
||||
async def insta_get_keyword(keyword_id: int) -> Optional[Dict[str, Any]]:
|
||||
items = await insta_list_keywords()
|
||||
for it in items:
|
||||
if it["id"] == keyword_id:
|
||||
return it
|
||||
return None
|
||||
|
||||
|
||||
async def insta_create_slate(keyword: str, category: str, keyword_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
resp = await _client.post(
|
||||
f"{BLOG_LAB_URL}/api/blog-marketing/research",
|
||||
json={"keyword": keyword},
|
||||
f"{INSTA_LAB_URL}/api/insta/slates",
|
||||
json={"keyword": keyword, "category": category, "keyword_id": keyword_id},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def blog_task_status(task_id: str) -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/task/{task_id}")
|
||||
async def insta_task_status(task_id: str) -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/tasks/{task_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def blog_generate(keyword_id: int) -> Dict[str, Any]:
|
||||
resp = await _client.post(
|
||||
f"{BLOG_LAB_URL}/api/blog-marketing/generate",
|
||||
json={"keyword_id": keyword_id},
|
||||
async def insta_get_slate(slate_id: int) -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def insta_get_asset_bytes(slate_id: int, page: int) -> bytes:
|
||||
"""카드 PNG 바이트를 가져와 텔레그램 미디어 그룹에 첨부."""
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.get(f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}/assets/{page}")
|
||||
resp.raise_for_status()
|
||||
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()
|
||||
|
||||
|
||||
async def blog_market(post_id: int) -> Dict[str, Any]:
|
||||
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/market/{post_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def blog_review(post_id: int) -> Dict[str, Any]:
|
||||
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/review/{post_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def blog_publish(post_id: int, url: str = "") -> Dict[str, Any]:
|
||||
resp = await _client.post(
|
||||
f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}/publish",
|
||||
json={"url": url},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def blog_get_post(post_id: int) -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# --- realestate-lab ---
|
||||
|
||||
async def realestate_collect() -> Dict[str, Any]:
|
||||
|
||||
@@ -37,6 +37,9 @@ async def _handle_callback(callback_query: dict) -> Optional[dict]:
|
||||
if callback_id.startswith("realestate_bookmark_"):
|
||||
return await _handle_realestate_bookmark(callback_query, callback_id)
|
||||
|
||||
if callback_id.startswith("render_"):
|
||||
return await _handle_insta_render(callback_query, callback_id)
|
||||
|
||||
cb = get_telegram_callback(callback_id)
|
||||
if not cb:
|
||||
return None
|
||||
@@ -97,6 +100,38 @@ async def _handle_realestate_bookmark(callback_query: dict, callback_id: str) ->
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _handle_insta_render(callback_query: dict, callback_id: str) -> dict:
|
||||
"""render_{keyword_id} 콜백 → InstaAgent.on_callback('render', ...).
|
||||
|
||||
텔레그램 인라인 버튼이 보낸 callback_data가 `render_<keyword_id>` 형식.
|
||||
InstaAgent._push_keyword_candidates가 callback_data를 그대로 박아 보내며,
|
||||
별도 DB lookup 없이 keyword_id를 파싱해 dispatch한다."""
|
||||
from .messaging import send_raw
|
||||
from ..agents import AGENT_REGISTRY
|
||||
|
||||
await api_call(
|
||||
"answerCallbackQuery",
|
||||
{"callback_query_id": callback_query["id"], "text": "카드 생성 시작"},
|
||||
)
|
||||
|
||||
try:
|
||||
keyword_id = int(callback_id.removeprefix("render_"))
|
||||
except ValueError:
|
||||
await send_raw("⚠️ 잘못된 render 콜백 데이터")
|
||||
return {"ok": False, "error": "invalid_callback_data"}
|
||||
|
||||
agent = AGENT_REGISTRY.get("insta")
|
||||
if not agent:
|
||||
await send_raw("⚠️ insta agent 미등록")
|
||||
return {"ok": False, "error": "agent_missing"}
|
||||
|
||||
try:
|
||||
return await agent.on_callback("render", {"keyword_id": keyword_id})
|
||||
except Exception as e:
|
||||
await send_raw(f"⚠️ 카드 생성 실패: {e}")
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]:
|
||||
"""슬래시 명령 메시지 처리."""
|
||||
from .router import parse_command, resolve_agent_command, HELP_TEXT
|
||||
|
||||
85
agent-office/tests/test_insta_agent.py
Normal file
@@ -0,0 +1,85 @@
|
||||
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 patch, AsyncMock, MagicMock
|
||||
|
||||
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_extract_dispatches(monkeypatch):
|
||||
agent = InstaAgent()
|
||||
fake_collect = AsyncMock(return_value={"task_id": "tcollect"})
|
||||
fake_extract = AsyncMock(return_value={"task_id": "textract"})
|
||||
fake_status = AsyncMock(side_effect=[
|
||||
{"status": "succeeded", "result_id": 0},
|
||||
{"status": "succeeded", "result_id": 0},
|
||||
])
|
||||
fake_keywords = AsyncMock(return_value=[
|
||||
{"id": 1, "keyword": "K1", "category": "economy", "score": 0.9},
|
||||
{"id": 2, "keyword": "K2", "category": "psychology", "score": 0.8},
|
||||
])
|
||||
|
||||
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.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||
|
||||
result = await agent.on_command("extract", {})
|
||||
assert result["ok"] is True
|
||||
fake_collect.assert_awaited()
|
||||
fake_extract.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_callback_render_kicks_pipeline(monkeypatch):
|
||||
agent = InstaAgent()
|
||||
fake_kw = AsyncMock(return_value={"id": 7, "keyword": "테스트", "category": "economy"})
|
||||
fake_create = AsyncMock(return_value={"task_id": "tslate"})
|
||||
fake_status = AsyncMock(side_effect=[
|
||||
{"status": "processing"},
|
||||
{"status": "succeeded", "result_id": 42},
|
||||
])
|
||||
fake_slate = AsyncMock(return_value={
|
||||
"id": 42, "status": "rendered",
|
||||
"suggested_caption": "캡션", "hashtags": ["#a", "#b"],
|
||||
"assets": [{"page_index": i, "file_path": f"/x/{i}.png"} for i in range(1, 11)],
|
||||
})
|
||||
fake_bytes = AsyncMock(side_effect=[b"PNG"] * 10)
|
||||
fake_send_media = AsyncMock(return_value={"ok": True})
|
||||
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_keyword", fake_kw)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_create_slate", fake_create)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate", fake_slate)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_asset_bytes", fake_bytes)
|
||||
monkeypatch.setattr("app.agents.insta._send_media_group", fake_send_media)
|
||||
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||
|
||||
out = await agent.on_callback("render", {"keyword_id": 7})
|
||||
assert out["ok"] is True
|
||||
fake_create.assert_awaited()
|
||||
fake_send_media.assert_awaited()
|
||||
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()
|
||||
@@ -1,6 +1,6 @@
|
||||
"""StockAgent.on_screener_schedule — 평일 16:30 KST 자동 잡 단위 테스트.
|
||||
|
||||
stock-lab HTTP 호출은 service_proxy mock, 텔레그램은 messaging.send_raw mock.
|
||||
stock HTTP 호출은 service_proxy mock, 텔레그램은 messaging.send_raw mock.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -138,7 +138,7 @@ def test_screener_run_failure_notifies_operator():
|
||||
from app.telegram import messaging
|
||||
|
||||
fake_snap = AsyncMock(return_value={"status": "ok"})
|
||||
fake_run = AsyncMock(side_effect=RuntimeError("stock-lab 500"))
|
||||
fake_run = AsyncMock(side_effect=RuntimeError("stock 500"))
|
||||
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||
|
||||
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
data/
|
||||
@@ -1,15 +0,0 @@
|
||||
FROM python:3.12-alpine
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -1,15 +0,0 @@
|
||||
import os
|
||||
|
||||
# Anthropic Claude API
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-20250514")
|
||||
|
||||
# Naver Search API
|
||||
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
|
||||
NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
|
||||
|
||||
# Database
|
||||
DB_PATH = os.getenv("BLOG_DB_PATH", "/app/data/blog_marketing.db")
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGINS = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080")
|
||||
@@ -1,172 +0,0 @@
|
||||
"""Claude API 기반 콘텐츠 생성 — 트렌드 브리프 + 블로그 글 작성."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
|
||||
from .db import get_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: Optional[anthropic.Anthropic] = None
|
||||
|
||||
|
||||
def _get_client() -> anthropic.Anthropic:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
return _client
|
||||
|
||||
|
||||
def _call_claude(prompt: str, max_tokens: int = 4096) -> str:
|
||||
"""Claude API 호출. 단일 user 메시지. 현재 날짜 시스템 프롬프트 포함."""
|
||||
client = _get_client()
|
||||
today = date.today().isoformat()
|
||||
resp = client.messages.create(
|
||||
model=CLAUDE_MODEL,
|
||||
max_tokens=max_tokens,
|
||||
system=f"현재 날짜는 {today}입니다. 모든 콘텐츠는 이 날짜 기준으로 작성하세요.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return resp.content[0].text
|
||||
|
||||
|
||||
def generate_trend_brief(analysis: Dict[str, Any]) -> str:
|
||||
"""키워드 분석 데이터를 바탕으로 트렌드 브리프 생성."""
|
||||
template = get_template("trend_brief")
|
||||
if not template:
|
||||
raise RuntimeError("trend_brief 템플릿이 없습니다")
|
||||
|
||||
top_blogs_text = "\n".join(
|
||||
f"- {b.get('title', '')}" for b in analysis.get("top_blogs", [])
|
||||
) or "없음"
|
||||
|
||||
top_products_text = "\n".join(
|
||||
f"- {p.get('title', '')} ({p.get('lprice', '?')}원, {p.get('mallName', '')})"
|
||||
for p in analysis.get("top_products", [])
|
||||
) or "없음"
|
||||
|
||||
prompt = template.format(
|
||||
keyword=analysis.get("keyword", ""),
|
||||
competition=analysis.get("competition", 0),
|
||||
opportunity=analysis.get("opportunity", 0),
|
||||
top_blogs=top_blogs_text,
|
||||
top_products=top_products_text,
|
||||
)
|
||||
|
||||
return _call_claude(prompt)
|
||||
|
||||
|
||||
def _parse_blog_json(raw: str, keyword: str) -> Dict[str, str]:
|
||||
"""Claude 응답에서 블로그 JSON을 파싱."""
|
||||
try:
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.split("\n")
|
||||
lines = [l for l in lines if not l.strip().startswith("```")]
|
||||
text = "\n".join(lines)
|
||||
result = json.loads(text)
|
||||
return {
|
||||
"title": result.get("title", ""),
|
||||
"body": result.get("body", ""),
|
||||
"excerpt": result.get("excerpt", ""),
|
||||
"tags": result.get("tags", []),
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
logger.warning("Blog post JSON parse failed, using raw text")
|
||||
return {
|
||||
"title": f"{keyword} 추천 리뷰",
|
||||
"body": raw,
|
||||
"excerpt": raw[:200],
|
||||
"tags": [keyword],
|
||||
}
|
||||
|
||||
|
||||
def generate_blog_post(
|
||||
analysis: Dict[str, Any],
|
||||
trend_brief: str,
|
||||
brand_links: Optional[list] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""트렌드 브리프를 바탕으로 블로그 글 작성.
|
||||
|
||||
Returns:
|
||||
{"title": str, "body": str, "excerpt": str, "tags": [...]}
|
||||
"""
|
||||
template = get_template("blog_write")
|
||||
if not template:
|
||||
raise RuntimeError("blog_write 템플릿이 없습니다")
|
||||
|
||||
top_products_text = "\n".join(
|
||||
f"- {p.get('title', '')} ({p.get('lprice', '?')}원, {p.get('mallName', '')})"
|
||||
for p in analysis.get("top_products", [])
|
||||
) or "없음"
|
||||
|
||||
# 크롤링된 블로그 본문 참고 자료
|
||||
reference_blogs_text = ""
|
||||
for blog in analysis.get("top_blogs", []):
|
||||
content = blog.get("content", "")
|
||||
if content:
|
||||
reference_blogs_text += f"\n### {blog.get('title', '제목 없음')}\n{content}\n"
|
||||
if not reference_blogs_text:
|
||||
reference_blogs_text = "없음"
|
||||
|
||||
# 브랜드커넥트 링크 정보
|
||||
brand_products_text = ""
|
||||
if brand_links:
|
||||
for link in brand_links:
|
||||
brand_products_text += (
|
||||
f"- 상품명: {link.get('product_name', '')}\n"
|
||||
f" 설명: {link.get('description', '')}\n"
|
||||
f" 링크: {link.get('url', '')}\n"
|
||||
f" 배치 힌트: {link.get('placement_hint', '자연스럽게')}\n"
|
||||
)
|
||||
if not brand_products_text:
|
||||
brand_products_text = "없음 (제휴 링크 없이 일반 리뷰로 작성)"
|
||||
|
||||
prompt = template.format(
|
||||
keyword=analysis.get("keyword", ""),
|
||||
trend_brief=trend_brief,
|
||||
top_products=top_products_text,
|
||||
reference_blogs=reference_blogs_text,
|
||||
brand_products=brand_products_text,
|
||||
)
|
||||
|
||||
# 구조화된 응답을 위한 추가 지시
|
||||
prompt += (
|
||||
"\n\n---\n"
|
||||
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력, 다른 텍스트 없이):\n"
|
||||
'{"title": "블로그 제목", "body": "HTML 본문", "excerpt": "2줄 요약", '
|
||||
'"tags": ["태그1", "태그2", ...]}'
|
||||
)
|
||||
|
||||
raw = _call_claude(prompt, max_tokens=8192)
|
||||
return _parse_blog_json(raw, analysis.get("keyword", ""))
|
||||
|
||||
|
||||
def regenerate_blog_post(
|
||||
analysis: Dict[str, Any],
|
||||
trend_brief: str,
|
||||
previous_body: str,
|
||||
feedback: str,
|
||||
) -> Dict[str, str]:
|
||||
"""피드백을 반영하여 블로그 글 재생성."""
|
||||
prompt = (
|
||||
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
|
||||
f"키워드: {analysis.get('keyword', '')}\n\n"
|
||||
f"이전에 작성한 글:\n{previous_body[:3000]}\n\n"
|
||||
f"리뷰어 피드백:\n{feedback}\n\n"
|
||||
"위 피드백을 반영하여 글을 개선해주세요.\n"
|
||||
"작성 규칙: 1인칭 체험기, 2,000자 이상, 자연스러운 구어체, "
|
||||
"제품 비교표 포함, 광고 고지 문구 포함.\n"
|
||||
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로.\n\n"
|
||||
"---\n"
|
||||
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력):\n"
|
||||
'{"title": "블로그 제목", "body": "HTML 본문", "excerpt": "2줄 요약", '
|
||||
'"tags": ["태그1", "태그2", ...]}'
|
||||
)
|
||||
raw = _call_claude(prompt, max_tokens=8192)
|
||||
return _parse_blog_json(raw, analysis.get("keyword", ""))
|
||||
@@ -1,789 +0,0 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
# 키워드/상품 분석 결과
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS keyword_analyses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
blog_total INTEGER NOT NULL DEFAULT 0,
|
||||
shop_total INTEGER NOT NULL DEFAULT 0,
|
||||
competition REAL NOT NULL DEFAULT 0,
|
||||
opportunity REAL NOT NULL DEFAULT 0,
|
||||
avg_price INTEGER,
|
||||
min_price INTEGER,
|
||||
max_price INTEGER,
|
||||
top_products TEXT NOT NULL DEFAULT '[]',
|
||||
top_blogs TEXT NOT NULL DEFAULT '[]',
|
||||
ai_summary TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ka_created ON keyword_analyses(created_at DESC)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ka_keyword ON keyword_analyses(keyword)")
|
||||
|
||||
# 블로그 포스트
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword_id INTEGER REFERENCES keyword_analyses(id),
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
excerpt TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
review_score INTEGER,
|
||||
review_detail TEXT NOT NULL DEFAULT '{}',
|
||||
naver_url TEXT NOT NULL DEFAULT '',
|
||||
trend_brief TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bp_created ON blog_posts(created_at DESC)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bp_status ON blog_posts(status)")
|
||||
|
||||
# 수익(커미션) 추적
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS commissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER REFERENCES blog_posts(id),
|
||||
month TEXT NOT NULL,
|
||||
clicks INTEGER NOT NULL DEFAULT 0,
|
||||
purchases INTEGER NOT NULL DEFAULT 0,
|
||||
revenue INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_comm_month ON commissions(month)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_comm_post ON commissions(post_id)")
|
||||
|
||||
# 비동기 작업 상태 (research / generate / review)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS generation_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'research',
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
result_id INTEGER,
|
||||
error TEXT,
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gt_created ON generation_tasks(created_at DESC)")
|
||||
|
||||
# AI 프롬프트 템플릿
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prompt_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
template TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# 브랜드커넥트 제휴 링크
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS brand_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER REFERENCES blog_posts(id),
|
||||
keyword_id INTEGER REFERENCES keyword_analyses(id),
|
||||
url TEXT NOT NULL,
|
||||
product_name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
placement_hint TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bl_post ON brand_links(post_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bl_keyword ON brand_links(keyword_id)")
|
||||
|
||||
# 기본 프롬프트 템플릿 시딩 (존재하지 않을 때만)
|
||||
_seed_templates(conn)
|
||||
_migrate_templates(conn)
|
||||
|
||||
|
||||
def _seed_templates(conn: sqlite3.Connection) -> None:
|
||||
"""기본 프롬프트 템플릿을 DB에 시딩."""
|
||||
templates = [
|
||||
{
|
||||
"name": "trend_brief",
|
||||
"description": "네이버 블로그 트렌드 분석 + 제목/훅 전략 브리프",
|
||||
"template": (
|
||||
"당신은 네이버 블로그 마케팅 전문가입니다.\n"
|
||||
"아래 키워드 분석 데이터를 바탕으로 블로그 포스팅 전략 브리프를 작성하세요.\n\n"
|
||||
"키워드: {keyword}\n"
|
||||
"블로그 경쟁도: {competition} (0-100, 높을수록 경쟁 치열)\n"
|
||||
"쇼핑 기회 점수: {opportunity} (0-100, 높을수록 기회 큼)\n"
|
||||
"상위 블로그 제목들: {top_blogs}\n"
|
||||
"상위 상품들: {top_products}\n\n"
|
||||
"다음을 포함해주세요:\n"
|
||||
"1. 클릭을 유도하는 제목 공식 3가지\n"
|
||||
"2. 도입부 훅 전략 (공감형, 질문형, 충격형 중 추천)\n"
|
||||
"3. 추천 해시태그 5-10개\n"
|
||||
"4. 경쟁 분석 요약 (기존 글 대비 차별화 포인트)\n"
|
||||
"5. SEO 키워드 배치 전략"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "blog_write",
|
||||
"description": "공감형 1인칭 체험기 블로그 글 작성",
|
||||
"template": (
|
||||
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
|
||||
"아래 브리프를 바탕으로 블로그 글을 작성하세요.\n\n"
|
||||
"키워드: {keyword}\n"
|
||||
"트렌드 브리프: {trend_brief}\n"
|
||||
"상위 상품 정보: {top_products}\n\n"
|
||||
"작성 규칙:\n"
|
||||
"- 1인칭 체험기 형식 (\"제가 직접 써봤는데요\")\n"
|
||||
"- 1,500자 이상\n"
|
||||
"- 자연스러운 구어체 (네이버 블로그 톤)\n"
|
||||
"- 제품 비교표 포함 (마크다운 테이블)\n"
|
||||
"- 장단점 솔직하게 작성\n"
|
||||
"- 광고 고지 문구 포함: \"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.\"\n"
|
||||
"- 추천 매트릭스 (가성비/품질/디자인 기준)\n"
|
||||
"- 자연스러운 CTA (구매 링크 유도)\n\n"
|
||||
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로 만들어주세요."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "quality_review",
|
||||
"description": "블로그 글 품질 리뷰 (6기준 × 10점)",
|
||||
"template": (
|
||||
"당신은 블로그 콘텐츠 품질 평가 전문가입니다.\n"
|
||||
"아래 블로그 글을 6가지 기준으로 평가해주세요.\n\n"
|
||||
"제목: {title}\n"
|
||||
"본문: {body}\n\n"
|
||||
"평가 기준 (각 1-10점):\n"
|
||||
"1. 독자 공감도 (empathy): 1인칭 체험기가 자연스럽고 공감되는가?\n"
|
||||
"2. 제목 클릭 유도력 (click_appeal): 검색 결과에서 클릭하고 싶은 제목인가?\n"
|
||||
"3. 구매 전환력 (conversion): 읽고 나서 제품을 사고 싶어지는가?\n"
|
||||
"4. SEO 최적화 (seo): 키워드 배치, 소제목, 길이가 적절한가?\n"
|
||||
"5. 형식 완성도 (format): 비교표, 이미지 설명, 단락 구성이 잘 되어있는가?\n"
|
||||
"6. 링크 자연스러움 (link_natural): 제휴 링크가 광고처럼 느껴지지 않고 자연스럽게 녹아있는가? (링크가 없으면 5점 기본)\n\n"
|
||||
"JSON 형식으로 응답:\n"
|
||||
"{{\n"
|
||||
" \"scores\": {{\n"
|
||||
" \"empathy\": N,\n"
|
||||
" \"click_appeal\": N,\n"
|
||||
" \"conversion\": N,\n"
|
||||
" \"seo\": N,\n"
|
||||
" \"format\": N,\n"
|
||||
" \"link_natural\": N\n"
|
||||
" }},\n"
|
||||
" \"total\": N,\n"
|
||||
" \"pass\": true/false,\n"
|
||||
" \"feedback\": \"개선 사항 설명\"\n"
|
||||
"}}"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "marketer_enhance",
|
||||
"description": "마케터 전환율 강화 + 제휴 링크 삽입",
|
||||
"template": (
|
||||
"당신은 네이버 블로그 수익화 전문 마케터입니다.\n"
|
||||
"아래 블로그 초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화하세요.\n\n"
|
||||
"=== 블로그 초안 ===\n{draft_body}\n\n"
|
||||
"=== 타겟 키워드 ===\n{keyword}\n\n"
|
||||
"=== 삽입할 제휴 링크 ===\n{brand_links_info}\n\n"
|
||||
"작업 규칙:\n"
|
||||
"- 제휴 링크를 <a href=\"URL\" target=\"_blank\">상품명</a> 형태로 본문 흐름에 맞게 2~3곳 삽입\n"
|
||||
"- 결론에 CTA(Call-to-Action) 블록 추가 (\"지금 확인하기\" 등)\n"
|
||||
"- 글 맨 아래에 광고 고지 문구 자동 삽입: \"이 포스팅은 브랜드로부터 소정의 수수료를 받을 수 있습니다\"\n"
|
||||
"- 작가의 1인칭 톤과 구어체를 유지\n"
|
||||
"- 과도한 광고 느낌 없이 자연스러운 추천 흐름 유지\n"
|
||||
"- 구매 심리를 자극하는 표현 강화 (한정 수량, 가격 비교, 실사용 만족도 등)\n"
|
||||
"- 배치 힌트가 있으면 참고하되, 문맥이 더 자연스러운 위치 우선\n"
|
||||
"- 기존 본문의 구조와 길이를 크게 변경하지 않음"
|
||||
),
|
||||
},
|
||||
]
|
||||
for t in templates:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM prompt_templates WHERE name = ?", (t["name"],)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_templates (name, description, template) VALUES (?, ?, ?)",
|
||||
(t["name"], t["description"], t["template"]),
|
||||
)
|
||||
|
||||
|
||||
def _migrate_templates(conn: sqlite3.Connection) -> None:
|
||||
"""기존 템플릿을 최신 버전으로 업데이트."""
|
||||
new_blog_write = (
|
||||
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
|
||||
"아래 브리프와 참고 자료를 바탕으로 블로그 글을 작성하세요.\n\n"
|
||||
"키워드: {keyword}\n"
|
||||
"트렌드 브리프: {trend_brief}\n\n"
|
||||
"=== 상위 블로그 참고 자료 ===\n"
|
||||
"{reference_blogs}\n\n"
|
||||
"=== 상위 상품 정보 ===\n"
|
||||
"{top_products}\n\n"
|
||||
"=== 제휴 상품 (브랜드커넥트 링크) ===\n"
|
||||
"{brand_products}\n\n"
|
||||
"작성 규칙:\n"
|
||||
"- 1인칭 체험기 형식 (\"제가 직접 써봤는데요\")\n"
|
||||
"- 2,000자 이상\n"
|
||||
"- 자연스러운 구어체 (네이버 블로그 톤)\n"
|
||||
"- 상위 블로그 참고하되 표절 금지 (자신만의 시각으로 재구성)\n"
|
||||
"- 제품 비교표 포함 (HTML 테이블)\n"
|
||||
"- 장단점 솔직하게 작성\n"
|
||||
"- 제휴 상품이 있으면 자연스럽게 체험 맥락에 녹여서 작성\n"
|
||||
"- 제휴 링크는 <a> 태그로 자연스럽게 삽입\n"
|
||||
"- 추천 매트릭스 (가성비/품질/디자인 기준)\n"
|
||||
"- 자연스러운 CTA (구매 링크 유도)\n\n"
|
||||
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로 만들어주세요."
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = 'blog_write'",
|
||||
(new_blog_write,),
|
||||
)
|
||||
|
||||
new_quality_review = (
|
||||
"당신은 블로그 콘텐츠 품질 평가 전문가입니다.\n"
|
||||
"아래 블로그 글을 6가지 기준으로 평가해주세요.\n\n"
|
||||
"제목: {title}\n"
|
||||
"본문: {body}\n\n"
|
||||
"평가 기준 (각 1-10점):\n"
|
||||
"1. 독자 공감도 (empathy): 1인칭 체험기가 자연스럽고 공감되는가?\n"
|
||||
"2. 제목 클릭 유도력 (click_appeal): 검색 결과에서 클릭하고 싶은 제목인가?\n"
|
||||
"3. 구매 전환력 (conversion): 읽고 나서 제품을 사고 싶어지는가?\n"
|
||||
"4. SEO 최적화 (seo): 키워드 배치, 소제목, 길이가 적절한가?\n"
|
||||
"5. 형식 완성도 (format): 비교표, 이미지 설명, 단락 구성이 잘 되어있는가?\n"
|
||||
"6. 링크 자연스러움 (link_natural): 제휴 링크가 광고처럼 느껴지지 않고 자연스럽게 녹아있는가? (링크가 없으면 5점 기본)\n\n"
|
||||
"JSON 형식으로 응답:\n"
|
||||
"{{\n"
|
||||
" \"scores\": {{\n"
|
||||
" \"empathy\": N,\n"
|
||||
" \"click_appeal\": N,\n"
|
||||
" \"conversion\": N,\n"
|
||||
" \"seo\": N,\n"
|
||||
" \"format\": N,\n"
|
||||
" \"link_natural\": N\n"
|
||||
" }},\n"
|
||||
" \"total\": N,\n"
|
||||
" \"pass\": true/false,\n"
|
||||
" \"feedback\": \"개선 사항 설명\"\n"
|
||||
"}}"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = 'quality_review'",
|
||||
(new_quality_review,),
|
||||
)
|
||||
|
||||
# marketer_enhance가 없으면 추가
|
||||
existing = conn.execute("SELECT id FROM prompt_templates WHERE name = 'marketer_enhance'").fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_templates (name, description, template) VALUES (?, ?, ?)",
|
||||
("marketer_enhance", "마케터 전환율 강화 + 제휴 링크 삽입",
|
||||
"당신은 네이버 블로그 수익화 전문 마케터입니다.\n"
|
||||
"아래 블로그 초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화하세요.\n\n"
|
||||
"=== 블로그 초안 ===\n{draft_body}\n\n"
|
||||
"=== 타겟 키워드 ===\n{keyword}\n\n"
|
||||
"=== 삽입할 제휴 링크 ===\n{brand_links_info}\n\n"
|
||||
"작업 규칙:\n"
|
||||
"- 제휴 링크를 <a href=\"URL\" target=\"_blank\">상품명</a> 형태로 본문 흐름에 맞게 2~3곳 삽입\n"
|
||||
"- 결론에 CTA(Call-to-Action) 블록 추가\n"
|
||||
"- 글 맨 아래에 광고 고지 문구 자동 삽입\n"
|
||||
"- 작가의 1인칭 톤과 구어체를 유지\n"
|
||||
"- 과도한 광고 느낌 없이 자연스러운 추천 흐름 유지"),
|
||||
)
|
||||
|
||||
|
||||
# ── keyword_analyses CRUD ────────────────────────────────────────────────────
|
||||
|
||||
def _ka_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"keyword": r["keyword"],
|
||||
"blog_total": r["blog_total"],
|
||||
"shop_total": r["shop_total"],
|
||||
"competition": r["competition"],
|
||||
"opportunity": r["opportunity"],
|
||||
"avg_price": r["avg_price"],
|
||||
"min_price": r["min_price"],
|
||||
"max_price": r["max_price"],
|
||||
"top_products": json.loads(r["top_products"]) if r["top_products"] else [],
|
||||
"top_blogs": json.loads(r["top_blogs"]) if r["top_blogs"] else [],
|
||||
"ai_summary": r["ai_summary"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_keyword_analysis(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO keyword_analyses
|
||||
(keyword, blog_total, shop_total, competition, opportunity,
|
||||
avg_price, min_price, max_price, top_products, top_blogs, ai_summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("keyword", ""),
|
||||
data.get("blog_total", 0),
|
||||
data.get("shop_total", 0),
|
||||
data.get("competition", 0),
|
||||
data.get("opportunity", 0),
|
||||
data.get("avg_price"),
|
||||
data.get("min_price"),
|
||||
data.get("max_price"),
|
||||
json.dumps(data.get("top_products", []), ensure_ascii=False),
|
||||
json.dumps(data.get("top_blogs", []), ensure_ascii=False),
|
||||
data.get("ai_summary", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM keyword_analyses WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _ka_row_to_dict(row)
|
||||
|
||||
|
||||
def get_keyword_analysis(analysis_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM keyword_analyses WHERE id = ?", (analysis_id,)
|
||||
).fetchone()
|
||||
return _ka_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def get_keyword_analyses(limit: int = 30) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM keyword_analyses ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [_ka_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_keyword_analysis(analysis_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM keyword_analyses WHERE id = ?", (analysis_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM keyword_analyses WHERE id = ?", (analysis_id,))
|
||||
return True
|
||||
|
||||
|
||||
# ── blog_posts CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
def _post_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"keyword_id": r["keyword_id"],
|
||||
"title": r["title"],
|
||||
"body": r["body"],
|
||||
"excerpt": r["excerpt"],
|
||||
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
||||
"status": r["status"],
|
||||
"review_score": r["review_score"],
|
||||
"review_detail": json.loads(r["review_detail"]) if r["review_detail"] else {},
|
||||
"naver_url": r["naver_url"],
|
||||
"trend_brief": r["trend_brief"],
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_post(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO blog_posts
|
||||
(keyword_id, title, body, excerpt, tags, status, review_score,
|
||||
review_detail, naver_url, trend_brief)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("keyword_id"),
|
||||
data.get("title", ""),
|
||||
data.get("body", ""),
|
||||
data.get("excerpt", ""),
|
||||
json.dumps(data.get("tags", []), ensure_ascii=False),
|
||||
data.get("status", "draft"),
|
||||
data.get("review_score"),
|
||||
json.dumps(data.get("review_detail", {}), ensure_ascii=False),
|
||||
data.get("naver_url", ""),
|
||||
data.get("trend_brief", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _post_row_to_dict(row)
|
||||
|
||||
|
||||
def get_post(post_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE id = ?", (post_id,)
|
||||
).fetchone()
|
||||
return _post_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def get_posts(status: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE status = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(status, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM blog_posts ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [_post_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_post(post_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
fields = []
|
||||
values = []
|
||||
for k in ("title", "body", "excerpt", "status", "naver_url", "trend_brief"):
|
||||
if k in data:
|
||||
fields.append(f"{k} = ?")
|
||||
values.append(data[k])
|
||||
if "tags" in data:
|
||||
fields.append("tags = ?")
|
||||
values.append(json.dumps(data["tags"], ensure_ascii=False))
|
||||
if "review_score" in data:
|
||||
fields.append("review_score = ?")
|
||||
values.append(data["review_score"])
|
||||
if "review_detail" in data:
|
||||
fields.append("review_detail = ?")
|
||||
values.append(json.dumps(data["review_detail"], ensure_ascii=False))
|
||||
if not fields:
|
||||
return get_post(post_id)
|
||||
fields.append("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
|
||||
values.append(post_id)
|
||||
conn.execute(
|
||||
f"UPDATE blog_posts SET {', '.join(fields)} WHERE id = ?", values
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE id = ?", (post_id,)
|
||||
).fetchone()
|
||||
return _post_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def delete_post(post_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM blog_posts WHERE id = ?", (post_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM blog_posts WHERE id = ?", (post_id,))
|
||||
return True
|
||||
|
||||
|
||||
# ── commissions CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
def _comm_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"post_id": r["post_id"],
|
||||
"month": r["month"],
|
||||
"clicks": r["clicks"],
|
||||
"purchases": r["purchases"],
|
||||
"revenue": r["revenue"],
|
||||
"note": r["note"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_commission(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO commissions (post_id, month, clicks, purchases, revenue, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("post_id"),
|
||||
data.get("month", ""),
|
||||
data.get("clicks", 0),
|
||||
data.get("purchases", 0),
|
||||
data.get("revenue", 0),
|
||||
data.get("note", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM commissions WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _comm_row_to_dict(row)
|
||||
|
||||
|
||||
def get_commissions(post_id: Optional[int] = None, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
if post_id:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM commissions WHERE post_id = ? ORDER BY month DESC LIMIT ?",
|
||||
(post_id, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM commissions ORDER BY month DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [_comm_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_commission(comm_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
fields = []
|
||||
values = []
|
||||
for k in ("month", "clicks", "purchases", "revenue", "note"):
|
||||
if k in data:
|
||||
fields.append(f"{k} = ?")
|
||||
values.append(data[k])
|
||||
if not fields:
|
||||
return None
|
||||
values.append(comm_id)
|
||||
conn.execute(
|
||||
f"UPDATE commissions SET {', '.join(fields)} WHERE id = ?", values
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM commissions WHERE id = ?", (comm_id,)
|
||||
).fetchone()
|
||||
return _comm_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def delete_commission(comm_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM commissions WHERE id = ?", (comm_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM commissions WHERE id = ?", (comm_id,))
|
||||
return True
|
||||
|
||||
|
||||
# ── brand_links CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
def _bl_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"post_id": r["post_id"],
|
||||
"keyword_id": r["keyword_id"],
|
||||
"url": r["url"],
|
||||
"product_name": r["product_name"],
|
||||
"description": r["description"],
|
||||
"placement_hint": r["placement_hint"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_brand_link(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO brand_links (post_id, keyword_id, url, product_name, description, placement_hint)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("post_id"),
|
||||
data.get("keyword_id"),
|
||||
data.get("url", ""),
|
||||
data.get("product_name", ""),
|
||||
data.get("description", ""),
|
||||
data.get("placement_hint", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM brand_links WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _bl_row_to_dict(row)
|
||||
|
||||
|
||||
def get_brand_links(
|
||||
post_id: Optional[int] = None,
|
||||
keyword_id: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
if post_id is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM brand_links WHERE post_id = ? ORDER BY id", (post_id,)
|
||||
).fetchall()
|
||||
elif keyword_id is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM brand_links WHERE keyword_id = ? ORDER BY id", (keyword_id,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM brand_links ORDER BY id DESC LIMIT 100").fetchall()
|
||||
return [_bl_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_brand_link(link_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
fields = []
|
||||
values = []
|
||||
for k in ("post_id", "keyword_id", "url", "product_name", "description", "placement_hint"):
|
||||
if k in data:
|
||||
fields.append(f"{k} = ?")
|
||||
values.append(data[k])
|
||||
if not fields:
|
||||
row = conn.execute("SELECT * FROM brand_links WHERE id = ?", (link_id,)).fetchone()
|
||||
return _bl_row_to_dict(row) if row else None
|
||||
values.append(link_id)
|
||||
conn.execute(f"UPDATE brand_links SET {', '.join(fields)} WHERE id = ?", values)
|
||||
row = conn.execute("SELECT * FROM brand_links WHERE id = ?", (link_id,)).fetchone()
|
||||
return _bl_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def delete_brand_link(link_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT id FROM brand_links WHERE id = ?", (link_id,)).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM brand_links WHERE id = ?", (link_id,))
|
||||
return True
|
||||
|
||||
|
||||
def link_brand_links_to_post(keyword_id: int, post_id: int) -> None:
|
||||
"""keyword_id로 등록된 링크들을 post_id에도 연결."""
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE brand_links SET post_id = ? WHERE keyword_id = ? AND post_id IS NULL",
|
||||
(post_id, keyword_id),
|
||||
)
|
||||
|
||||
|
||||
def get_dashboard_stats() -> Dict[str, Any]:
|
||||
"""대시보드 집계: 총 포스트/클릭/구매/수익 + 월별 추이."""
|
||||
with _conn() as conn:
|
||||
total_posts = conn.execute("SELECT COUNT(*) FROM blog_posts").fetchone()[0]
|
||||
published = conn.execute(
|
||||
"SELECT COUNT(*) FROM blog_posts WHERE status = 'published'"
|
||||
).fetchone()[0]
|
||||
|
||||
agg = conn.execute(
|
||||
"SELECT COALESCE(SUM(clicks),0), COALESCE(SUM(purchases),0), COALESCE(SUM(revenue),0) FROM commissions"
|
||||
).fetchone()
|
||||
|
||||
monthly = conn.execute(
|
||||
"""SELECT month, SUM(clicks) as clicks, SUM(purchases) as purchases, SUM(revenue) as revenue
|
||||
FROM commissions GROUP BY month ORDER BY month DESC LIMIT 12"""
|
||||
).fetchall()
|
||||
|
||||
top_posts = conn.execute(
|
||||
"""SELECT bp.id, bp.title, COALESCE(SUM(c.revenue),0) as total_revenue
|
||||
FROM blog_posts bp LEFT JOIN commissions c ON c.post_id = bp.id
|
||||
GROUP BY bp.id ORDER BY total_revenue DESC LIMIT 5"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"total_posts": total_posts,
|
||||
"published_posts": published,
|
||||
"total_clicks": agg[0],
|
||||
"total_purchases": agg[1],
|
||||
"total_revenue": agg[2],
|
||||
"monthly": [
|
||||
{"month": r["month"], "clicks": r["clicks"], "purchases": r["purchases"], "revenue": r["revenue"]}
|
||||
for r in monthly
|
||||
],
|
||||
"top_posts": [
|
||||
{"id": r["id"], "title": r["title"], "total_revenue": r["total_revenue"]}
|
||||
for r in top_posts
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── generation_tasks CRUD ────────────────────────────────────────────────────
|
||||
|
||||
def _task_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"task_id": r["id"],
|
||||
"type": r["type"],
|
||||
"status": r["status"],
|
||||
"progress": r["progress"],
|
||||
"message": r["message"],
|
||||
"result_id": r["result_id"],
|
||||
"error": r["error"],
|
||||
"params": json.loads(r["params"]) if r["params"] else {},
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def create_task(task_id: str, task_type: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO generation_tasks (id, type, params) VALUES (?, ?, ?)",
|
||||
(task_id, task_type, json.dumps(params, ensure_ascii=False)),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM generation_tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return _task_row_to_dict(row)
|
||||
|
||||
|
||||
def update_task(
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: int,
|
||||
message: str,
|
||||
result_id: Optional[int] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE generation_tasks
|
||||
SET status = ?, progress = ?, message = ?, result_id = ?, error = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id = ?""",
|
||||
(status, progress, message, result_id, error, task_id),
|
||||
)
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM generation_tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return _task_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
# ── prompt_templates CRUD ────────────────────────────────────────────────────
|
||||
|
||||
def get_template(name: str) -> Optional[str]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT template FROM prompt_templates WHERE name = ?", (name,)
|
||||
).fetchone()
|
||||
return row["template"] if row else None
|
||||
|
||||
|
||||
def get_all_templates() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM prompt_templates ORDER BY name").fetchall()
|
||||
return [
|
||||
{"id": r["id"], "name": r["name"], "description": r["description"],
|
||||
"template": r["template"], "updated_at": r["updated_at"]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def update_template(name: str, template: str) -> bool:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = ?",
|
||||
(template, name),
|
||||
)
|
||||
return conn.execute(
|
||||
"SELECT id FROM prompt_templates WHERE name = ?", (name,)
|
||||
).fetchone() is not None
|
||||
@@ -1,440 +0,0 @@
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
from .config import CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY
|
||||
from .db import (
|
||||
init_db,
|
||||
get_keyword_analyses, get_keyword_analysis, delete_keyword_analysis,
|
||||
add_keyword_analysis,
|
||||
get_posts, get_post, add_post, update_post, delete_post,
|
||||
get_commissions, add_commission, update_commission, delete_commission,
|
||||
get_dashboard_stats,
|
||||
get_task, create_task, update_task,
|
||||
add_brand_link, get_brand_links, update_brand_link, delete_brand_link,
|
||||
link_brand_links_to_post,
|
||||
)
|
||||
from .naver_search import analyze_keyword_with_crawling
|
||||
from .content_generator import generate_trend_brief, generate_blog_post, regenerate_blog_post
|
||||
from .quality_reviewer import review_post
|
||||
from .marketer import enhance_for_conversion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in _cors_origins],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
os.makedirs("/app/data", exist_ok=True)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/status")
|
||||
def service_status():
|
||||
"""서비스 상태 및 설정 현황."""
|
||||
return {
|
||||
"ok": True,
|
||||
"naver_api": bool(NAVER_CLIENT_ID),
|
||||
"claude_api": bool(ANTHROPIC_API_KEY),
|
||||
}
|
||||
|
||||
|
||||
# ── 키워드 분석 API ──────────────────────────────────────────────────────────
|
||||
|
||||
class ResearchRequest(BaseModel):
|
||||
keyword: str
|
||||
|
||||
|
||||
def _run_research(task_id: str, keyword: str):
|
||||
"""BackgroundTask: 네이버 검색 → 키워드 분석 → DB 저장."""
|
||||
try:
|
||||
update_task(task_id, "processing", 30, "네이버 검색 중...")
|
||||
result = analyze_keyword_with_crawling(keyword)
|
||||
|
||||
update_task(task_id, "processing", 80, "분석 결과 저장 중...")
|
||||
saved = add_keyword_analysis(result)
|
||||
|
||||
update_task(task_id, "succeeded", 100, "분석 완료", result_id=saved["id"])
|
||||
except Exception as e:
|
||||
logger.exception("Research failed for keyword=%s", keyword)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/research")
|
||||
def start_research(req: ResearchRequest, background_tasks: BackgroundTasks):
|
||||
"""키워드 분석 시작 (BackgroundTask). task_id 즉시 반환."""
|
||||
if not NAVER_CLIENT_ID:
|
||||
raise HTTPException(status_code=400, detail="Naver API 키가 설정되지 않았습니다")
|
||||
if not req.keyword.strip():
|
||||
raise HTTPException(status_code=400, detail="키워드를 입력하세요")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "research", {"keyword": req.keyword.strip()})
|
||||
background_tasks.add_task(_run_research, task_id, req.keyword.strip())
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/research/history")
|
||||
def list_research(limit: int = Query(30, ge=1, le=100)):
|
||||
return {"analyses": get_keyword_analyses(limit)}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/research/{analysis_id}")
|
||||
def get_research(analysis_id: int):
|
||||
result = get_keyword_analysis(analysis_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/research/{analysis_id}")
|
||||
def remove_research(analysis_id: int):
|
||||
if not delete_keyword_analysis(analysis_id):
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 작업 상태 폴링 API ──────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/task/{task_id}")
|
||||
def get_task_status(task_id: str):
|
||||
task = get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
|
||||
# ── AI 글 생성 API ──────────────────────────────────────────────────────────
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
keyword_id: int # keyword_analyses.id
|
||||
|
||||
|
||||
class LinkRequest(BaseModel):
|
||||
url: str
|
||||
product_name: str
|
||||
keyword_id: Optional[int] = None
|
||||
post_id: Optional[int] = None
|
||||
description: str = ""
|
||||
placement_hint: str = ""
|
||||
|
||||
|
||||
def _run_generate(task_id: str, keyword_id: int):
|
||||
"""BackgroundTask: 트렌드 브리프 → 블로그 글 생성 → DB 저장."""
|
||||
try:
|
||||
analysis = get_keyword_analysis(keyword_id)
|
||||
if not analysis:
|
||||
update_task(task_id, "failed", 0, "", error="키워드 분석 결과를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
# 연결된 브랜드커넥트 링크 조회
|
||||
brand_links = get_brand_links(keyword_id=keyword_id)
|
||||
|
||||
update_task(task_id, "processing", 20, "트렌드 브리프 생성 중...")
|
||||
trend_brief = generate_trend_brief(analysis)
|
||||
|
||||
update_task(task_id, "processing", 60, "블로그 글 작성 중...")
|
||||
post_data = generate_blog_post(analysis, trend_brief, brand_links=brand_links)
|
||||
|
||||
update_task(task_id, "processing", 90, "저장 중...")
|
||||
saved = add_post({
|
||||
"keyword_id": keyword_id,
|
||||
"title": post_data["title"],
|
||||
"body": post_data["body"],
|
||||
"excerpt": post_data["excerpt"],
|
||||
"tags": post_data["tags"],
|
||||
"status": "draft",
|
||||
"trend_brief": trend_brief,
|
||||
})
|
||||
|
||||
# keyword_id에 연결된 링크를 post_id에도 연결
|
||||
link_brand_links_to_post(keyword_id=keyword_id, post_id=saved["id"])
|
||||
|
||||
update_task(task_id, "succeeded", 100, "글 생성 완료", result_id=saved["id"])
|
||||
except Exception as e:
|
||||
logger.exception("Generate failed for keyword_id=%s", keyword_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/generate")
|
||||
def start_generate(req: GenerateRequest, background_tasks: BackgroundTasks):
|
||||
"""AI 블로그 글 생성 시작. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
analysis = get_keyword_analysis(req.keyword_id)
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail="키워드 분석 결과를 찾을 수 없습니다")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "generate", {"keyword_id": req.keyword_id})
|
||||
background_tasks.add_task(_run_generate, task_id, req.keyword_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 품질 리뷰 API ───────────────────────────────────────────────────────────
|
||||
|
||||
def _run_review(task_id: str, post_id: int):
|
||||
"""BackgroundTask: 블로그 글 품질 리뷰."""
|
||||
try:
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 50, "품질 리뷰 중...")
|
||||
result = review_post(post["title"], post["body"])
|
||||
|
||||
update_post(post_id, {
|
||||
"review_score": result["total"],
|
||||
"review_detail": result,
|
||||
"status": "reviewed" if result["pass"] else "draft",
|
||||
})
|
||||
|
||||
update_task(task_id, "succeeded", 100, "리뷰 완료", result_id=post_id)
|
||||
except Exception as e:
|
||||
logger.exception("Review failed for post_id=%s", post_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/review/{post_id}")
|
||||
def start_review(post_id: int, background_tasks: BackgroundTasks):
|
||||
"""블로그 글 품질 리뷰 시작. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "review", {"post_id": post_id})
|
||||
background_tasks.add_task(_run_review, task_id, post_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 재생성 API ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _run_regenerate(task_id: str, post_id: int):
|
||||
"""BackgroundTask: 피드백 기반 블로그 글 재생성."""
|
||||
try:
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
analysis = get_keyword_analysis(post["keyword_id"]) if post["keyword_id"] else {}
|
||||
feedback = post.get("review_detail", {}).get("feedback", "개선이 필요합니다")
|
||||
|
||||
update_task(task_id, "processing", 50, "글 재생성 중...")
|
||||
result = regenerate_blog_post(
|
||||
analysis or {"keyword": ""},
|
||||
post.get("trend_brief", ""),
|
||||
post["body"],
|
||||
feedback,
|
||||
)
|
||||
|
||||
update_post(post_id, {
|
||||
"title": result["title"],
|
||||
"body": result["body"],
|
||||
"excerpt": result["excerpt"],
|
||||
"tags": result["tags"],
|
||||
"status": "draft",
|
||||
"review_score": None,
|
||||
"review_detail": {},
|
||||
})
|
||||
|
||||
update_task(task_id, "succeeded", 100, "재생성 완료", result_id=post_id)
|
||||
except Exception as e:
|
||||
logger.exception("Regenerate failed for post_id=%s", post_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/regenerate/{post_id}")
|
||||
def start_regenerate(post_id: int, background_tasks: BackgroundTasks):
|
||||
"""피드백 기반 블로그 글 재생성. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "regenerate", {"post_id": post_id})
|
||||
background_tasks.add_task(_run_regenerate, task_id, post_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 포스트 CRUD API ──────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/posts")
|
||||
def list_posts(status: str = None, limit: int = Query(50, ge=1, le=100)):
|
||||
return {"posts": get_posts(status=status, limit=limit)}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/posts/{post_id}")
|
||||
def get_post_detail(post_id: int):
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return post
|
||||
|
||||
|
||||
@app.put("/api/blog-marketing/posts/{post_id}")
|
||||
def edit_post(post_id: int, data: dict):
|
||||
result = update_post(post_id, data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/posts/{post_id}")
|
||||
def remove_post(post_id: int):
|
||||
if not delete_post(post_id):
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/posts/{post_id}/publish")
|
||||
def publish_post(post_id: int, data: dict = None):
|
||||
"""네이버 URL 등록 + 상태를 published로 변경."""
|
||||
naver_url = (data or {}).get("naver_url", "")
|
||||
result = update_post(post_id, {"status": "published", "naver_url": naver_url})
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return result
|
||||
|
||||
|
||||
# ── 브랜드커넥트 링크 API ──────────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/blog-marketing/links", status_code=201)
|
||||
def create_link(req: LinkRequest):
|
||||
return add_brand_link(req.model_dump())
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/links")
|
||||
def list_links(post_id: int = None, keyword_id: int = None):
|
||||
return {"links": get_brand_links(post_id=post_id, keyword_id=keyword_id)}
|
||||
|
||||
|
||||
@app.put("/api/blog-marketing/links/{link_id}")
|
||||
def edit_link(link_id: int, data: dict):
|
||||
result = update_brand_link(link_id, data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/links/{link_id}")
|
||||
def remove_link(link_id: int):
|
||||
if not delete_brand_link(link_id):
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 마케터 API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _run_market(task_id: str, post_id: int):
|
||||
"""BackgroundTask: 마케터 전환율 강화."""
|
||||
try:
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
brand_links = get_brand_links(post_id=post_id)
|
||||
if not brand_links and post.get("keyword_id"):
|
||||
brand_links = get_brand_links(keyword_id=post["keyword_id"])
|
||||
|
||||
if not brand_links:
|
||||
update_task(task_id, "failed", 0, "", error="브랜드커넥트 링크가 없습니다. 먼저 링크를 등록하세요.")
|
||||
return
|
||||
|
||||
analysis = get_keyword_analysis(post["keyword_id"]) if post.get("keyword_id") else {}
|
||||
keyword = (analysis or {}).get("keyword", "")
|
||||
|
||||
update_task(task_id, "processing", 50, "마케터가 전환율 강화 중...")
|
||||
result = enhance_for_conversion(
|
||||
post_body=post["body"],
|
||||
post_title=post["title"],
|
||||
brand_links=brand_links,
|
||||
keyword=keyword,
|
||||
)
|
||||
|
||||
update_post(post_id, {
|
||||
"title": result["title"],
|
||||
"body": result["body"],
|
||||
"excerpt": result["excerpt"],
|
||||
"status": "marketed",
|
||||
})
|
||||
|
||||
update_task(task_id, "succeeded", 100, "마케팅 강화 완료", result_id=post_id)
|
||||
except Exception as e:
|
||||
logger.exception("Market failed for post_id=%s", post_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/market/{post_id}")
|
||||
def start_market(post_id: int, background_tasks: BackgroundTasks):
|
||||
"""마케터 단계 실행. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "market", {"post_id": post_id})
|
||||
background_tasks.add_task(_run_market, task_id, post_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 수익 추적 API ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/commissions")
|
||||
def list_commissions(post_id: int = None, limit: int = Query(100, ge=1, le=100)):
|
||||
return {"commissions": get_commissions(post_id=post_id, limit=limit)}
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/commissions", status_code=201)
|
||||
def create_commission(data: dict):
|
||||
return add_commission(data)
|
||||
|
||||
|
||||
@app.put("/api/blog-marketing/commissions/{comm_id}")
|
||||
def edit_commission(comm_id: int, data: dict):
|
||||
result = update_commission(comm_id, data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Commission not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/commissions/{comm_id}")
|
||||
def remove_commission(comm_id: int):
|
||||
if not delete_commission(comm_id):
|
||||
raise HTTPException(status_code=404, detail="Commission not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 대시보드 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/dashboard")
|
||||
def dashboard():
|
||||
return get_dashboard_stats()
|
||||
@@ -1,105 +0,0 @@
|
||||
"""마케터 단계 — 전환율 강화 + 브랜드커넥트 링크 삽입."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
|
||||
from .db import get_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: Optional[anthropic.Anthropic] = None
|
||||
|
||||
|
||||
def _get_client() -> anthropic.Anthropic:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
return _client
|
||||
|
||||
|
||||
def _call_claude(prompt: str, max_tokens: int = 8192) -> str:
|
||||
client = _get_client()
|
||||
today = date.today().isoformat()
|
||||
resp = client.messages.create(
|
||||
model=CLAUDE_MODEL,
|
||||
max_tokens=max_tokens,
|
||||
system=f"현재 날짜는 {today}입니다. 모든 콘텐츠는 이 날짜 기준으로 작성하세요.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return resp.content[0].text
|
||||
|
||||
|
||||
def enhance_for_conversion(
|
||||
post_body: str,
|
||||
post_title: str,
|
||||
brand_links: List[Dict[str, Any]],
|
||||
keyword: str,
|
||||
) -> Dict[str, str]:
|
||||
"""초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화.
|
||||
|
||||
Args:
|
||||
post_body: 작가 초안 HTML 본문
|
||||
post_title: 작가 초안 제목
|
||||
brand_links: 브랜드커넥트 링크 리스트
|
||||
keyword: 타겟 키워드
|
||||
|
||||
Returns:
|
||||
{"title": str, "body": str, "excerpt": str}
|
||||
|
||||
Raises:
|
||||
ValueError: 브랜드 링크가 없을 때
|
||||
"""
|
||||
if not brand_links:
|
||||
raise ValueError("브랜드커넥트 링크가 필요합니다")
|
||||
|
||||
template = get_template("marketer_enhance")
|
||||
if not template:
|
||||
raise RuntimeError("marketer_enhance 템플릿이 없습니다")
|
||||
|
||||
brand_links_text = ""
|
||||
for i, link in enumerate(brand_links, 1):
|
||||
brand_links_text += (
|
||||
f"{i}. 상품명: {link.get('product_name', '')}\n"
|
||||
f" 설명: {link.get('description', '')}\n"
|
||||
f" URL: {link.get('url', '')}\n"
|
||||
f" 배치 힌트: {link.get('placement_hint', '자연스럽게')}\n\n"
|
||||
)
|
||||
|
||||
prompt = template.format(
|
||||
draft_body=post_body[:6000],
|
||||
keyword=keyword,
|
||||
brand_links_info=brand_links_text,
|
||||
)
|
||||
|
||||
prompt += (
|
||||
"\n\n---\n"
|
||||
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력):\n"
|
||||
'{"title": "개선된 제목", "body": "개선된 HTML 본문", "excerpt": "2줄 요약"}'
|
||||
)
|
||||
|
||||
raw = _call_claude(prompt)
|
||||
|
||||
try:
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.split("\n")
|
||||
lines = [l for l in lines if not l.strip().startswith("```")]
|
||||
text = "\n".join(lines)
|
||||
result = json.loads(text)
|
||||
return {
|
||||
"title": result.get("title", post_title),
|
||||
"body": result.get("body", post_body),
|
||||
"excerpt": result.get("excerpt", ""),
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
logger.warning("Marketer JSON parse failed, using raw text")
|
||||
return {
|
||||
"title": post_title,
|
||||
"body": raw,
|
||||
"excerpt": raw[:200],
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
"""네이버 검색 API 연동 — 블로그 + 쇼핑 검색."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import requests
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .config import NAVER_CLIENT_ID, NAVER_CLIENT_SECRET
|
||||
|
||||
BLOG_URL = "https://openapi.naver.com/v1/search/blog.json"
|
||||
SHOP_URL = "https://openapi.naver.com/v1/search/shop.json"
|
||||
|
||||
_HEADERS = {
|
||||
"X-Naver-Client-Id": NAVER_CLIENT_ID,
|
||||
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
|
||||
}
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
return _TAG_RE.sub("", text).strip()
|
||||
|
||||
|
||||
def search_blog(keyword: str, display: int = 10, sort: str = "sim") -> Dict[str, Any]:
|
||||
"""네이버 블로그 검색.
|
||||
|
||||
Args:
|
||||
keyword: 검색 키워드
|
||||
display: 결과 수 (1-100)
|
||||
sort: sim(정확도) | date(날짜)
|
||||
|
||||
Returns:
|
||||
{"total": int, "items": [...]}
|
||||
"""
|
||||
resp = requests.get(
|
||||
BLOG_URL,
|
||||
headers=_HEADERS,
|
||||
params={"query": keyword, "display": display, "sort": sort},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = [
|
||||
{
|
||||
"title": _strip_html(item.get("title", "")),
|
||||
"description": _strip_html(item.get("description", "")),
|
||||
"link": item.get("link", ""),
|
||||
"bloggername": item.get("bloggername", ""),
|
||||
"postdate": item.get("postdate", ""),
|
||||
}
|
||||
for item in data.get("items", [])
|
||||
]
|
||||
return {"total": data.get("total", 0), "items": items}
|
||||
|
||||
|
||||
def search_shopping(keyword: str, display: int = 20, sort: str = "sim") -> Dict[str, Any]:
|
||||
"""네이버 쇼핑 검색.
|
||||
|
||||
Args:
|
||||
keyword: 검색 키워드
|
||||
display: 결과 수 (1-100)
|
||||
sort: sim(정확도) | date(날짜) | asc(가격↑) | dsc(가격↓)
|
||||
|
||||
Returns:
|
||||
{"total": int, "items": [...], "price_stats": {...}}
|
||||
"""
|
||||
resp = requests.get(
|
||||
SHOP_URL,
|
||||
headers=_HEADERS,
|
||||
params={"query": keyword, "display": display, "sort": sort},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
items = []
|
||||
prices = []
|
||||
for item in data.get("items", []):
|
||||
lprice = _safe_int(item.get("lprice"))
|
||||
hprice = _safe_int(item.get("hprice"))
|
||||
parsed = {
|
||||
"title": _strip_html(item.get("title", "")),
|
||||
"link": item.get("link", ""),
|
||||
"image": item.get("image", ""),
|
||||
"lprice": lprice,
|
||||
"hprice": hprice,
|
||||
"mallName": item.get("mallName", ""),
|
||||
"productId": item.get("productId", ""),
|
||||
"productType": item.get("productType", ""),
|
||||
"category1": item.get("category1", ""),
|
||||
"category2": item.get("category2", ""),
|
||||
"category3": item.get("category3", ""),
|
||||
"brand": item.get("brand", ""),
|
||||
"maker": item.get("maker", ""),
|
||||
}
|
||||
items.append(parsed)
|
||||
if lprice and lprice > 0:
|
||||
prices.append(lprice)
|
||||
|
||||
price_stats = None
|
||||
if prices:
|
||||
price_stats = {
|
||||
"min": min(prices),
|
||||
"max": max(prices),
|
||||
"avg": int(sum(prices) / len(prices)),
|
||||
"count": len(prices),
|
||||
}
|
||||
|
||||
return {
|
||||
"total": data.get("total", 0),
|
||||
"items": items,
|
||||
"price_stats": price_stats,
|
||||
}
|
||||
|
||||
|
||||
def _safe_int(val) -> Optional[int]:
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def analyze_keyword(keyword: str) -> Dict[str, Any]:
|
||||
"""키워드 경쟁도/기회 분석.
|
||||
|
||||
블로그 총 결과수, 쇼핑 총 결과수, 가격 통계를 기반으로
|
||||
competition_score(경쟁도)와 opportunity_score(기회점수) 산출.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"keyword", "blog_total", "shop_total",
|
||||
"competition", "opportunity",
|
||||
"avg_price", "min_price", "max_price",
|
||||
"top_products": [...], "top_blogs": [...]
|
||||
}
|
||||
"""
|
||||
blog = search_blog(keyword, display=10, sort="sim")
|
||||
shop = search_shopping(keyword, display=20, sort="sim")
|
||||
|
||||
blog_total = blog["total"]
|
||||
shop_total = shop["total"]
|
||||
|
||||
# 경쟁도: 블로그 결과 수 기반 (로그 스케일 0-100)
|
||||
import math
|
||||
if blog_total > 0:
|
||||
competition = min(100, int(math.log10(blog_total + 1) * 15))
|
||||
else:
|
||||
competition = 0
|
||||
|
||||
# 기회 점수: 쇼핑 수요가 높고 블로그 경쟁이 낮을수록 높음
|
||||
if shop_total > 0 and blog_total > 0:
|
||||
ratio = shop_total / blog_total
|
||||
opportunity = min(100, int(ratio * 20))
|
||||
elif shop_total > 0:
|
||||
opportunity = 90 # 경쟁 없이 수요만 있으면 높은 기회
|
||||
else:
|
||||
opportunity = 10 # 쇼핑 수요 없음
|
||||
|
||||
price_stats = shop.get("price_stats") or {}
|
||||
|
||||
return {
|
||||
"keyword": keyword,
|
||||
"blog_total": blog_total,
|
||||
"shop_total": shop_total,
|
||||
"competition": competition,
|
||||
"opportunity": opportunity,
|
||||
"avg_price": price_stats.get("avg"),
|
||||
"min_price": price_stats.get("min"),
|
||||
"max_price": price_stats.get("max"),
|
||||
"top_products": shop["items"][:5],
|
||||
"top_blogs": blog["items"][:5],
|
||||
}
|
||||
|
||||
|
||||
def _run_enrich(top_blogs: list) -> list:
|
||||
"""동기 컨텍스트에서 비동기 enrich_top_blogs 실행."""
|
||||
from .web_crawler import enrich_top_blogs
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
return pool.submit(
|
||||
asyncio.run, enrich_top_blogs(top_blogs)
|
||||
).result(timeout=60)
|
||||
else:
|
||||
return asyncio.run(enrich_top_blogs(top_blogs))
|
||||
except Exception as e:
|
||||
logger.warning("블로그 크롤링 실패, 기존 데이터 사용: %s", e)
|
||||
return top_blogs
|
||||
|
||||
|
||||
def analyze_keyword_with_crawling(keyword: str) -> Dict[str, Any]:
|
||||
"""analyze_keyword + 상위 블로그 본문 크롤링."""
|
||||
result = analyze_keyword(keyword)
|
||||
result["top_blogs"] = _run_enrich(result["top_blogs"])
|
||||
return result
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Claude API 기반 블로그 글 품질 리뷰 — 6기준 × 10점, 42/60 통과."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
|
||||
from .db import get_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PASS_THRESHOLD = 42 # 60점 만점 중 42점 이상이면 통과 (70%)
|
||||
|
||||
_client: Optional[anthropic.Anthropic] = None
|
||||
|
||||
|
||||
def _get_client() -> anthropic.Anthropic:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
return _client
|
||||
|
||||
|
||||
def review_post(title: str, body: str) -> Dict[str, Any]:
|
||||
"""블로그 글 품질 리뷰.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"scores": {
|
||||
"empathy": N, "click_appeal": N, "conversion": N,
|
||||
"seo": N, "format": N, "link_natural": N
|
||||
},
|
||||
"total": N,
|
||||
"pass": bool,
|
||||
"feedback": str
|
||||
}
|
||||
"""
|
||||
template = get_template("quality_review")
|
||||
if not template:
|
||||
raise RuntimeError("quality_review 템플릿이 없습니다")
|
||||
|
||||
prompt = template.format(title=title, body=body[:6000])
|
||||
|
||||
client = _get_client()
|
||||
today = date.today().isoformat()
|
||||
resp = client.messages.create(
|
||||
model=CLAUDE_MODEL,
|
||||
max_tokens=2048,
|
||||
system=f"현재 날짜는 {today}입니다.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
raw = resp.content[0].text
|
||||
|
||||
try:
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.split("\n")
|
||||
lines = [l for l in lines if not l.strip().startswith("```")]
|
||||
text = "\n".join(lines)
|
||||
result = json.loads(text)
|
||||
|
||||
scores = result.get("scores", {})
|
||||
total = sum(scores.values())
|
||||
passed = total >= PASS_THRESHOLD
|
||||
|
||||
return {
|
||||
"scores": scores,
|
||||
"total": total,
|
||||
"pass": passed,
|
||||
"feedback": result.get("feedback", ""),
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
||||
logger.warning("Quality review JSON parse failed: %s", e)
|
||||
return {
|
||||
"scores": {
|
||||
"empathy": 0, "click_appeal": 0, "conversion": 0,
|
||||
"seo": 0, "format": 0, "link_natural": 0,
|
||||
},
|
||||
"total": 0,
|
||||
"pass": False,
|
||||
"feedback": f"리뷰 파싱 실패. 원본 응답:\n{raw[:500]}",
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"""네이버 블로그 본문 크롤링 모듈."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TIMEOUT = 10 # 글당 크롤링 타임아웃 (초)
|
||||
_MAX_CONTENT_LENGTH = 2000 # 본문 최대 길이
|
||||
|
||||
# 네이버 블로그 URL 패턴: blog.naver.com/{blogId}/{logNo}
|
||||
_BLOG_URL_RE = re.compile(r"blog\.naver\.com/([^/]+)/(\d+)")
|
||||
|
||||
|
||||
def _parse_naver_blog_url(url: str) -> Optional[Tuple[str, str]]:
|
||||
"""네이버 블로그 URL에서 blogId, logNo 추출. 실패 시 None."""
|
||||
match = _BLOG_URL_RE.search(url)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
|
||||
async def _fetch_html(url: str) -> str:
|
||||
"""URL에서 HTML을 가져온다."""
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
})
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def _extract_text(html: str) -> str:
|
||||
"""HTML에서 본문 텍스트를 추출한다."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
# 스마트에디터 3 (SE3)
|
||||
container = soup.select_one("div.se-main-container")
|
||||
if not container:
|
||||
# 구 에디터
|
||||
container = soup.select_one("div#postViewArea")
|
||||
if not container:
|
||||
# 폴백: body 전체
|
||||
container = soup.body
|
||||
|
||||
if not container:
|
||||
return ""
|
||||
|
||||
# 스크립트/스타일 제거
|
||||
for tag in container.find_all(["script", "style"]):
|
||||
tag.decompose()
|
||||
|
||||
text = container.get_text(separator="\n", strip=True)
|
||||
return text[:_MAX_CONTENT_LENGTH]
|
||||
|
||||
|
||||
async def crawl_blog_content(url: str) -> str:
|
||||
"""네이버 블로그 URL에서 본문 텍스트 추출.
|
||||
|
||||
- 네이버 블로그가 아니면 빈 문자열
|
||||
- 크롤링 실패 시 빈 문자열 (에러 로그만)
|
||||
- 본문 최대 2,000자
|
||||
"""
|
||||
parsed = _parse_naver_blog_url(url)
|
||||
if not parsed:
|
||||
return ""
|
||||
|
||||
blog_id, log_no = parsed
|
||||
# iframe 내부 실제 본문 URL
|
||||
post_url = f"https://blog.naver.com/PostView.naver?blogId={blog_id}&logNo={log_no}"
|
||||
|
||||
try:
|
||||
html = await _fetch_html(post_url)
|
||||
return _extract_text(html)
|
||||
except Exception as e:
|
||||
logger.warning("블로그 크롤링 실패 (%s): %s", url, e)
|
||||
return ""
|
||||
|
||||
|
||||
async def enrich_top_blogs(top_blogs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""top_blogs 리스트 각 항목에 content 필드를 추가.
|
||||
|
||||
개별 크롤링 실패 시 해당 항목의 content를 빈 문자열로 설정하고 나머지 계속 진행.
|
||||
"""
|
||||
result = []
|
||||
for blog in top_blogs:
|
||||
enriched = dict(blog)
|
||||
try:
|
||||
enriched["content"] = await crawl_blog_content(blog.get("link", ""))
|
||||
except Exception:
|
||||
enriched["content"] = ""
|
||||
result.append(enriched)
|
||||
return result
|
||||
@@ -1,9 +0,0 @@
|
||||
"""공통 테스트 픽스처."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# app 패키지를 blog_lab_app으로도 import 가능하게
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
if "blog_lab_app" not in sys.modules:
|
||||
import app as blog_lab_app
|
||||
sys.modules["blog_lab_app"] = blog_lab_app
|
||||
@@ -1,85 +0,0 @@
|
||||
"""브랜드커넥트 링크 API 테스트."""
|
||||
import os
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(tmp_path):
|
||||
test_db = str(tmp_path / "test.db")
|
||||
import app.config as config
|
||||
config.DB_PATH = test_db
|
||||
from app import db
|
||||
db.DB_PATH = test_db
|
||||
db.init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_create_link(client):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": 1,
|
||||
"url": "https://link.coupang.com/abc",
|
||||
"product_name": "테스트 상품",
|
||||
"description": "상품 설명",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["url"] == "https://link.coupang.com/abc"
|
||||
assert data["product_name"] == "테스트 상품"
|
||||
|
||||
|
||||
def test_create_link_requires_url(client):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"product_name": "상품",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_create_link_requires_product_name(client):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"url": "https://a.com",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_list_links_by_keyword_id(client):
|
||||
client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": 1, "url": "https://a.com", "product_name": "A",
|
||||
})
|
||||
client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": 2, "url": "https://b.com", "product_name": "B",
|
||||
})
|
||||
resp = client.get("/api/blog-marketing/links?keyword_id=1")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["links"]) == 1
|
||||
|
||||
|
||||
def test_update_link(client):
|
||||
create_resp = client.post("/api/blog-marketing/links", json={
|
||||
"url": "https://a.com", "product_name": "원래",
|
||||
})
|
||||
link_id = create_resp.json()["id"]
|
||||
resp = client.put(f"/api/blog-marketing/links/{link_id}", json={
|
||||
"product_name": "새이름",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["product_name"] == "새이름"
|
||||
|
||||
|
||||
def test_delete_link(client):
|
||||
create_resp = client.post("/api/blog-marketing/links", json={
|
||||
"url": "https://a.com", "product_name": "삭제",
|
||||
})
|
||||
link_id = create_resp.json()["id"]
|
||||
resp = client.delete(f"/api/blog-marketing/links/{link_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
|
||||
resp = client.delete(f"/api/blog-marketing/links/{link_id}")
|
||||
assert resp.status_code == 404
|
||||
@@ -1,67 +0,0 @@
|
||||
"""brand_links DB CRUD 테스트."""
|
||||
import os
|
||||
import pytest
|
||||
from app import db
|
||||
from app.config import DB_PATH
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(tmp_path):
|
||||
"""테스트용 임시 DB 사용."""
|
||||
test_db = str(tmp_path / "test.db")
|
||||
import app.config as config
|
||||
config.DB_PATH = test_db
|
||||
db.DB_PATH = test_db
|
||||
db.init_db()
|
||||
yield
|
||||
|
||||
|
||||
def test_add_brand_link():
|
||||
link = db.add_brand_link({
|
||||
"keyword_id": 1,
|
||||
"url": "https://link.coupang.com/abc",
|
||||
"product_name": "테스트 상품",
|
||||
"description": "상품 설명",
|
||||
"placement_hint": "본문 중간",
|
||||
})
|
||||
assert link["id"] is not None
|
||||
assert link["url"] == "https://link.coupang.com/abc"
|
||||
assert link["product_name"] == "테스트 상품"
|
||||
assert link["keyword_id"] == 1
|
||||
assert link["post_id"] is None
|
||||
|
||||
|
||||
def test_get_brand_links_by_keyword_id():
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://a.com", "product_name": "A"})
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://b.com", "product_name": "B"})
|
||||
db.add_brand_link({"keyword_id": 2, "url": "https://c.com", "product_name": "C"})
|
||||
links = db.get_brand_links(keyword_id=1)
|
||||
assert len(links) == 2
|
||||
|
||||
|
||||
def test_get_brand_links_by_post_id():
|
||||
db.add_brand_link({"post_id": 10, "url": "https://a.com", "product_name": "A"})
|
||||
links = db.get_brand_links(post_id=10)
|
||||
assert len(links) == 1
|
||||
assert links[0]["post_id"] == 10
|
||||
|
||||
|
||||
def test_update_brand_link():
|
||||
link = db.add_brand_link({"url": "https://a.com", "product_name": "원래 이름"})
|
||||
updated = db.update_brand_link(link["id"], {"product_name": "새 이름", "post_id": 5})
|
||||
assert updated["product_name"] == "새 이름"
|
||||
assert updated["post_id"] == 5
|
||||
|
||||
|
||||
def test_delete_brand_link():
|
||||
link = db.add_brand_link({"url": "https://a.com", "product_name": "삭제할 링크"})
|
||||
assert db.delete_brand_link(link["id"]) is True
|
||||
assert db.delete_brand_link(link["id"]) is False
|
||||
|
||||
|
||||
def test_link_keyword_to_post():
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://a.com", "product_name": "A"})
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://b.com", "product_name": "B"})
|
||||
db.link_brand_links_to_post(keyword_id=1, post_id=10)
|
||||
links = db.get_brand_links(post_id=10)
|
||||
assert len(links) == 2
|
||||
@@ -1,74 +0,0 @@
|
||||
"""평가자 단계 테스트 — 6기준 60점."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_review_post_has_6_criteria():
|
||||
"""6개 기준으로 채점하는지 확인."""
|
||||
from app.quality_reviewer import review_post
|
||||
|
||||
mock_response = json.dumps({
|
||||
"scores": {
|
||||
"empathy": 8, "click_appeal": 7, "conversion": 9,
|
||||
"seo": 8, "format": 7, "link_natural": 9,
|
||||
},
|
||||
"total": 48,
|
||||
"pass": True,
|
||||
"feedback": "전체적으로 우수합니다",
|
||||
})
|
||||
|
||||
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
|
||||
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
|
||||
mock_client = mock_client_fn.return_value
|
||||
mock_client.messages.create.return_value.content = [type("C", (), {"text": mock_response})()]
|
||||
result = review_post("테스트 제목", "<p>본문</p>")
|
||||
|
||||
assert "link_natural" in result["scores"]
|
||||
assert len(result["scores"]) == 6
|
||||
assert result["total"] == 48
|
||||
assert result["pass"] is True
|
||||
|
||||
|
||||
def test_review_pass_threshold_is_42():
|
||||
"""통과 기준이 42점인지 확인."""
|
||||
from app.quality_reviewer import PASS_THRESHOLD
|
||||
assert PASS_THRESHOLD == 42
|
||||
|
||||
|
||||
def test_review_fails_below_42():
|
||||
"""42점 미만이면 불통과."""
|
||||
from app.quality_reviewer import review_post
|
||||
|
||||
mock_response = json.dumps({
|
||||
"scores": {
|
||||
"empathy": 5, "click_appeal": 5, "conversion": 5,
|
||||
"seo": 5, "format": 5, "link_natural": 5,
|
||||
},
|
||||
"total": 30,
|
||||
"pass": False,
|
||||
"feedback": "개선 필요",
|
||||
})
|
||||
|
||||
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
|
||||
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
|
||||
mock_client = mock_client_fn.return_value
|
||||
mock_client.messages.create.return_value.content = [type("C", (), {"text": mock_response})()]
|
||||
result = review_post("제목", "<p>본문</p>")
|
||||
|
||||
assert result["pass"] is False
|
||||
|
||||
|
||||
def test_review_handles_parse_failure():
|
||||
"""JSON 파싱 실패 시 기본값 반환 (6개 기준)."""
|
||||
from app.quality_reviewer import review_post
|
||||
|
||||
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
|
||||
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
|
||||
mock_client = mock_client_fn.return_value
|
||||
mock_client.messages.create.return_value.content = [type("C", (), {"text": "잘못된 응답"})()]
|
||||
result = review_post("제목", "<p>본문</p>")
|
||||
|
||||
assert result["pass"] is False
|
||||
assert "link_natural" in result["scores"]
|
||||
assert result["total"] == 0
|
||||
@@ -1,66 +0,0 @@
|
||||
"""마케터 단계 테스트."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_enhance_for_conversion_inserts_links():
|
||||
"""마케터가 브랜드 링크를 본문에 삽입."""
|
||||
from app.marketer import enhance_for_conversion
|
||||
|
||||
brand_links = [
|
||||
{"url": "https://link.coupang.com/abc", "product_name": "갤럭시 버즈3",
|
||||
"description": "노이즈캔슬링", "placement_hint": "본문 중간"},
|
||||
]
|
||||
|
||||
mock_response = json.dumps({
|
||||
"title": "마케팅된 제목",
|
||||
"body": '<p>본문 <a href="https://link.coupang.com/abc">갤럭시 버즈3</a></p>',
|
||||
"excerpt": "요약",
|
||||
})
|
||||
|
||||
with patch("app.marketer._call_claude", return_value=mock_response) as mock_call, \
|
||||
patch("app.marketer.get_template", return_value="초안: {draft_body}\n키워드: {keyword}\n링크:\n{brand_links_info}"):
|
||||
result = enhance_for_conversion(
|
||||
post_body="<p>초안 본문</p>",
|
||||
post_title="초안 제목",
|
||||
brand_links=brand_links,
|
||||
keyword="무선 이어폰",
|
||||
)
|
||||
|
||||
prompt_used = mock_call.call_args[0][0]
|
||||
assert "갤럭시 버즈3" in prompt_used
|
||||
assert "노이즈캔슬링" in prompt_used
|
||||
assert result["title"] == "마케팅된 제목"
|
||||
|
||||
|
||||
def test_enhance_requires_brand_links():
|
||||
"""브랜드 링크가 없으면 ValueError."""
|
||||
from app.marketer import enhance_for_conversion
|
||||
|
||||
with pytest.raises(ValueError, match="브랜드커넥트 링크가 필요합니다"):
|
||||
enhance_for_conversion(
|
||||
post_body="<p>본문</p>",
|
||||
post_title="제목",
|
||||
brand_links=[],
|
||||
keyword="테스트",
|
||||
)
|
||||
|
||||
|
||||
def test_enhance_json_parse_fallback():
|
||||
"""JSON 파싱 실패 시 원본 제목 유지."""
|
||||
from app.marketer import enhance_for_conversion
|
||||
|
||||
brand_links = [{"url": "https://a.com", "product_name": "상품"}]
|
||||
|
||||
with patch("app.marketer._call_claude", return_value="잘못된 JSON"), \
|
||||
patch("app.marketer.get_template", return_value="초안: {draft_body}\n키워드: {keyword}\n링크:\n{brand_links_info}"):
|
||||
result = enhance_for_conversion(
|
||||
post_body="<p>원본</p>",
|
||||
post_title="원본 제목",
|
||||
brand_links=brand_links,
|
||||
keyword="테스트",
|
||||
)
|
||||
|
||||
assert result["title"] == "원본 제목"
|
||||
assert result["body"] == "잘못된 JSON"
|
||||
@@ -1,146 +0,0 @@
|
||||
"""4단계 파이프라인 통합 테스트."""
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(tmp_path):
|
||||
test_db = str(tmp_path / "test.db")
|
||||
import app.config as config
|
||||
config.DB_PATH = test_db
|
||||
from app import db
|
||||
db.DB_PATH = test_db
|
||||
db.init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_full_pipeline_status_flow(client):
|
||||
"""draft → marketed → reviewed → published 상태 흐름."""
|
||||
from app import db
|
||||
|
||||
# 1. 키워드 분석 결과 직접 삽입
|
||||
analysis = db.add_keyword_analysis({
|
||||
"keyword": "무선 이어폰",
|
||||
"blog_total": 1000,
|
||||
"shop_total": 500,
|
||||
"competition": 45,
|
||||
"opportunity": 60,
|
||||
"top_products": [{"title": "에어팟", "lprice": 200000, "mallName": "애플"}],
|
||||
"top_blogs": [{"title": "리뷰", "link": "https://blog.naver.com/user/123", "content": "본문"}],
|
||||
})
|
||||
|
||||
# 2. 브랜드 링크 등록
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": analysis["id"],
|
||||
"url": "https://link.coupang.com/abc",
|
||||
"product_name": "삼성 버즈3",
|
||||
"description": "노이즈캔슬링",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
|
||||
# 3. 포스트 직접 생성 (generate는 Claude API 필요)
|
||||
post = db.add_post({
|
||||
"keyword_id": analysis["id"],
|
||||
"title": "무선 이어폰 추천",
|
||||
"body": "<p>초안 본문</p>",
|
||||
"excerpt": "요약",
|
||||
"tags": ["이어폰"],
|
||||
"status": "draft",
|
||||
})
|
||||
db.link_brand_links_to_post(keyword_id=analysis["id"], post_id=post["id"])
|
||||
|
||||
# 4. 상태 확인: draft
|
||||
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
|
||||
assert resp.json()["status"] == "draft"
|
||||
|
||||
# 5. marketed 상태
|
||||
db.update_post(post["id"], {"status": "marketed", "body": "<p>마케팅된 본문</p>"})
|
||||
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
|
||||
assert resp.json()["status"] == "marketed"
|
||||
|
||||
# 6. reviewed 상태 (점수 48/60 = 통과)
|
||||
db.update_post(post["id"], {
|
||||
"status": "reviewed",
|
||||
"review_score": 48,
|
||||
"review_detail": {
|
||||
"scores": {"empathy": 8, "click_appeal": 8, "conversion": 8, "seo": 8, "format": 8, "link_natural": 8},
|
||||
"total": 48, "pass": True, "feedback": "우수"
|
||||
},
|
||||
})
|
||||
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
|
||||
assert resp.json()["status"] == "reviewed"
|
||||
assert resp.json()["review_score"] == 48
|
||||
|
||||
# 7. 발행
|
||||
resp = client.post(f"/api/blog-marketing/posts/{post['id']}/publish", json={
|
||||
"naver_url": "https://blog.naver.com/mypost/123",
|
||||
})
|
||||
assert resp.json()["status"] == "published"
|
||||
|
||||
|
||||
def test_links_associated_with_post(client):
|
||||
"""keyword_id로 등록한 링크가 post 생성 후 post_id로도 조회 가능."""
|
||||
from app import db
|
||||
|
||||
analysis = db.add_keyword_analysis({"keyword": "테스트", "blog_total": 10, "shop_total": 5})
|
||||
client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": analysis["id"],
|
||||
"url": "https://link.com/1",
|
||||
"product_name": "상품1",
|
||||
})
|
||||
|
||||
post = db.add_post({"keyword_id": analysis["id"], "title": "제목", "body": "본문", "status": "draft"})
|
||||
db.link_brand_links_to_post(keyword_id=analysis["id"], post_id=post["id"])
|
||||
|
||||
resp = client.get(f"/api/blog-marketing/links?post_id={post['id']}")
|
||||
links = resp.json()["links"]
|
||||
assert len(links) == 1
|
||||
assert links[0]["product_name"] == "상품1"
|
||||
|
||||
|
||||
@patch("app.main.ANTHROPIC_API_KEY", "fake-key-for-test")
|
||||
def test_market_endpoint_returns_404_for_missing_post(client):
|
||||
"""존재하지 않는 post_id로 마케터 호출 시 404."""
|
||||
resp = client.post("/api/blog-marketing/market/9999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@patch("app.main.ANTHROPIC_API_KEY", "fake-key-for-test")
|
||||
def test_review_endpoint_returns_404_for_missing_post(client):
|
||||
"""존재하지 않는 post_id로 리뷰 호출 시 404."""
|
||||
resp = client.post("/api/blog-marketing/review/9999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_multiple_links_per_keyword(client):
|
||||
"""하나의 키워드에 복수 링크 등록 가능."""
|
||||
from app import db
|
||||
analysis = db.add_keyword_analysis({"keyword": "테스트", "blog_total": 10, "shop_total": 5})
|
||||
|
||||
for i in range(3):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": analysis["id"],
|
||||
"url": f"https://link.com/{i}",
|
||||
"product_name": f"상품{i}",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = client.get(f"/api/blog-marketing/links?keyword_id={analysis['id']}")
|
||||
assert len(resp.json()["links"]) == 3
|
||||
|
||||
|
||||
def test_dashboard_still_works(client):
|
||||
"""대시보드 API가 여전히 정상 작동."""
|
||||
resp = client.get("/api/blog-marketing/dashboard")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "total_posts" in data
|
||||
assert "published_posts" in data
|
||||
@@ -1,58 +0,0 @@
|
||||
"""리서치 단계 크롤링 통합 테스트."""
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_analyze_keyword_with_crawling_enriches_top_blogs():
|
||||
"""analyze_keyword_with_crawling가 top_blogs에 content 필드를 추가."""
|
||||
from app.naver_search import analyze_keyword_with_crawling
|
||||
|
||||
mock_blog_result = {
|
||||
"total": 100,
|
||||
"items": [
|
||||
{"title": "테스트 블로그", "link": "https://blog.naver.com/user1/111",
|
||||
"bloggername": "유저1", "description": "설명", "postdate": "20260401"},
|
||||
],
|
||||
}
|
||||
mock_shop_result = {
|
||||
"total": 50,
|
||||
"items": [{"title": "상품1", "lprice": 10000, "mallName": "쿠팡"}],
|
||||
"price_stats": {"min": 10000, "max": 10000, "avg": 10000, "count": 1},
|
||||
}
|
||||
|
||||
with patch("app.naver_search.search_blog", return_value=mock_blog_result), \
|
||||
patch("app.naver_search.search_shopping", return_value=mock_shop_result), \
|
||||
patch("app.naver_search._run_enrich", return_value=[
|
||||
{"title": "테스트 블로그", "link": "https://blog.naver.com/user1/111",
|
||||
"bloggername": "유저1", "description": "설명", "postdate": "20260401",
|
||||
"content": "크롤링된 본문 내용"}
|
||||
]):
|
||||
result = analyze_keyword_with_crawling("테스트 키워드")
|
||||
|
||||
assert "content" in result["top_blogs"][0]
|
||||
assert result["top_blogs"][0]["content"] == "크롤링된 본문 내용"
|
||||
|
||||
|
||||
def test_analyze_keyword_with_crawling_fallback_on_enrich_failure():
|
||||
"""크롤링 실패 시 기존 데이터 유지."""
|
||||
from app.naver_search import analyze_keyword_with_crawling
|
||||
|
||||
mock_blog_result = {
|
||||
"total": 50,
|
||||
"items": [{"title": "블로그", "link": "https://blog.naver.com/u/1", "bloggername": "유저", "description": "설명"}],
|
||||
}
|
||||
mock_shop_result = {"total": 10, "items": [], "price_stats": None}
|
||||
|
||||
with patch("app.naver_search.search_blog", return_value=mock_blog_result), \
|
||||
patch("app.naver_search.search_shopping", return_value=mock_shop_result), \
|
||||
patch("app.naver_search._run_enrich", side_effect=Exception("크롤링 실패")):
|
||||
# _run_enrich 내부에서 예외를 잡으므로 실제로는 이 테스트에서는
|
||||
# _run_enrich 자체가 예외를 던지는 상황을 시뮬레이션
|
||||
# 하지만 _run_enrich는 내부에서 잡으므로, 직접 fallback 테스트
|
||||
pass
|
||||
|
||||
# _run_enrich 자체 fallback 테스트
|
||||
from app.naver_search import _run_enrich
|
||||
original_blogs = [{"title": "원본", "link": "https://blog.naver.com/u/1"}]
|
||||
with patch("app.web_crawler.enrich_top_blogs", side_effect=Exception("fail")):
|
||||
result = _run_enrich(original_blogs)
|
||||
assert result == original_blogs # fallback으로 원본 반환
|
||||
@@ -1,94 +0,0 @@
|
||||
"""web_crawler 모듈 테스트."""
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from app.web_crawler import crawl_blog_content, enrich_top_blogs, _parse_naver_blog_url, _extract_text
|
||||
|
||||
|
||||
def test_parse_naver_blog_url_valid():
|
||||
"""blog.naver.com URL에서 blogId와 logNo를 올바르게 파싱."""
|
||||
result = _parse_naver_blog_url("https://blog.naver.com/testuser/123456")
|
||||
assert result == ("testuser", "123456")
|
||||
|
||||
|
||||
def test_parse_returns_none_for_invalid_url():
|
||||
"""잘못된 URL은 None 반환."""
|
||||
result = _parse_naver_blog_url("https://example.com/post")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_extract_text_prefers_se_main_container():
|
||||
"""SE3 에디터 컨테이너를 우선 선택."""
|
||||
html = '<div class="se-main-container"><p>SE3 본문</p></div><div id="postViewArea"><p>구 에디터</p></div>'
|
||||
assert _extract_text(html) == "SE3 본문"
|
||||
|
||||
|
||||
def test_extract_text_falls_back_to_post_view_area():
|
||||
"""SE3 없으면 구 에디터 컨테이너 사용."""
|
||||
html = '<div id="postViewArea"><p>구 에디터 본문</p></div>'
|
||||
assert _extract_text(html) == "구 에디터 본문"
|
||||
|
||||
|
||||
def test_extract_text_removes_script_and_style():
|
||||
"""스크립트/스타일 태그 제거."""
|
||||
html = '<div class="se-main-container"><p>본문</p><script>alert(1)</script><style>.x{}</style></div>'
|
||||
result = _extract_text(html)
|
||||
assert "alert" not in result
|
||||
assert ".x" not in result
|
||||
assert "본문" in result
|
||||
|
||||
|
||||
def test_extract_text_returns_empty_on_no_container():
|
||||
"""컨테이너가 없고 body도 없으면 빈 문자열."""
|
||||
assert _extract_text("") == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_returns_empty_on_non_naver_url():
|
||||
"""네이버 블로그가 아닌 URL은 빈 문자열 반환."""
|
||||
result = await crawl_blog_content("https://example.com/post")
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_truncates_to_2000_chars():
|
||||
"""본문이 2000자를 초과하면 잘라낸다."""
|
||||
long_html = f'<div class="se-main-container"><p>{"가" * 3000}</p></div>'
|
||||
with patch("app.web_crawler._fetch_html", new_callable=AsyncMock, return_value=long_html):
|
||||
result = await crawl_blog_content("https://blog.naver.com/testuser/123")
|
||||
assert len(result) <= 2000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_returns_empty_on_fetch_failure():
|
||||
"""HTTP 요청 실패 시 빈 문자열 반환."""
|
||||
with patch("app.web_crawler._fetch_html", new_callable=AsyncMock, side_effect=Exception("timeout")):
|
||||
result = await crawl_blog_content("https://blog.naver.com/testuser/123")
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_top_blogs_adds_content_field():
|
||||
"""enrich_top_blogs가 각 블로그에 content 필드를 추가."""
|
||||
blogs = [
|
||||
{"title": "테스트", "link": "https://blog.naver.com/user1/111", "bloggername": "유저1", "description": "설명"},
|
||||
{"title": "테스트2", "link": "https://blog.naver.com/user2/222", "bloggername": "유저2", "description": "설명2"},
|
||||
]
|
||||
with patch("app.web_crawler.crawl_blog_content", new_callable=AsyncMock, return_value="크롤링된 본문"):
|
||||
result = await enrich_top_blogs(blogs)
|
||||
assert len(result) == 2
|
||||
assert result[0]["content"] == "크롤링된 본문"
|
||||
assert result[1]["content"] == "크롤링된 본문"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_top_blogs_handles_partial_failure():
|
||||
"""일부 크롤링 실패 시에도 나머지는 정상 처리."""
|
||||
blogs = [
|
||||
{"title": "성공", "link": "https://blog.naver.com/user1/111"},
|
||||
{"title": "실패", "link": "https://blog.naver.com/user2/222"},
|
||||
]
|
||||
side_effects = ["성공 본문", Exception("fail")]
|
||||
with patch("app.web_crawler.crawl_blog_content", new_callable=AsyncMock, side_effect=side_effects):
|
||||
result = await enrich_top_blogs(blogs)
|
||||
assert result[0]["content"] == "성공 본문"
|
||||
assert result[1]["content"] == ""
|
||||
@@ -1,86 +0,0 @@
|
||||
"""작가 단계 테스트 -- 크롤링 본문 + 링크 참조 글 생성."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_generate_blog_post_includes_crawled_content():
|
||||
"""크롤링 본문이 프롬프트에 포함되는지 확인."""
|
||||
from app.content_generator import generate_blog_post
|
||||
|
||||
analysis = {
|
||||
"keyword": "무선 이어폰",
|
||||
"top_products": [{"title": "에어팟", "lprice": 200000, "mallName": "애플"}],
|
||||
"top_blogs": [
|
||||
{"title": "에어팟 리뷰", "content": "에어팟을 한 달간 써봤는데 음질이 정말 좋았습니다."},
|
||||
],
|
||||
}
|
||||
|
||||
mock_response = json.dumps({
|
||||
"title": "무선 이어폰 추천",
|
||||
"body": "<p>본문</p>",
|
||||
"excerpt": "요약",
|
||||
"tags": ["이어폰"],
|
||||
})
|
||||
|
||||
with patch("app.content_generator._call_claude", return_value=mock_response) as mock_call, \
|
||||
patch("app.content_generator.get_template", return_value=(
|
||||
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
|
||||
)):
|
||||
result = generate_blog_post(analysis, "트렌드 브리프", brand_links=[])
|
||||
|
||||
prompt_used = mock_call.call_args[0][0]
|
||||
assert "에어팟을 한 달간 써봤는데" in prompt_used
|
||||
assert result["title"] == "무선 이어폰 추천"
|
||||
|
||||
|
||||
def test_generate_blog_post_includes_brand_links():
|
||||
"""브랜드커넥트 링크 정보가 프롬프트에 포함되는지 확인."""
|
||||
from app.content_generator import generate_blog_post
|
||||
|
||||
analysis = {"keyword": "무선 이어폰", "top_products": [], "top_blogs": []}
|
||||
brand_links = [
|
||||
{"url": "https://link.coupang.com/abc", "product_name": "삼성 버즈3",
|
||||
"description": "노이즈캔슬링 지원", "placement_hint": "본문 중간"},
|
||||
]
|
||||
|
||||
mock_response = json.dumps({
|
||||
"title": "제목", "body": "<p>본문</p>", "excerpt": "요약", "tags": ["태그"],
|
||||
})
|
||||
|
||||
with patch("app.content_generator._call_claude", return_value=mock_response) as mock_call, \
|
||||
patch("app.content_generator.get_template", return_value=(
|
||||
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
|
||||
)):
|
||||
result = generate_blog_post(analysis, "트렌드 브리프", brand_links=brand_links)
|
||||
|
||||
prompt_used = mock_call.call_args[0][0]
|
||||
assert "삼성 버즈3" in prompt_used
|
||||
assert "노이즈캔슬링 지원" in prompt_used
|
||||
|
||||
|
||||
def test_generate_blog_post_works_without_links():
|
||||
"""링크 없이도 정상 동작."""
|
||||
from app.content_generator import generate_blog_post
|
||||
|
||||
analysis = {"keyword": "테스트", "top_products": [], "top_blogs": []}
|
||||
mock_response = json.dumps({
|
||||
"title": "제목", "body": "<p>본문</p>", "excerpt": "요약", "tags": ["태그"],
|
||||
})
|
||||
|
||||
with patch("app.content_generator._call_claude", return_value=mock_response), \
|
||||
patch("app.content_generator.get_template", return_value=(
|
||||
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
|
||||
)):
|
||||
result = generate_blog_post(analysis, "브리프")
|
||||
|
||||
assert result["title"] == "제목"
|
||||
|
||||
|
||||
def test_parse_blog_json_fallback():
|
||||
"""JSON 파싱 실패 시 원본 텍스트를 body로 사용."""
|
||||
from app.content_generator import _parse_blog_json
|
||||
|
||||
result = _parse_blog_json("잘못된 JSON", "테스트 키워드")
|
||||
assert result["title"] == "테스트 키워드 추천 리뷰"
|
||||
assert result["body"] == "잘못된 JSON"
|
||||
@@ -22,12 +22,12 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
stock-lab:
|
||||
stock:
|
||||
build:
|
||||
context: ./stock-lab
|
||||
context: ./stock
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
container_name: stock-lab
|
||||
container_name: stock
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18500:8000"
|
||||
@@ -43,6 +43,7 @@ services:
|
||||
- OLLAMA_URL=${OLLAMA_URL:-http://192.168.45.59:11435}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- WEBAI_API_KEY=${WEBAI_API_KEY:-}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/stock:/app/data
|
||||
healthcheck:
|
||||
@@ -85,21 +86,27 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
blog-lab:
|
||||
insta-lab:
|
||||
build:
|
||||
context: ./blog-lab
|
||||
container_name: blog-lab
|
||||
context: ./insta-lab
|
||||
container_name: insta-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18700:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- ANTHROPIC_MODEL_HAIKU=${ANTHROPIC_MODEL_HAIKU:-claude-haiku-4-5-20251001}
|
||||
- ANTHROPIC_MODEL_SONNET=${ANTHROPIC_MODEL_SONNET:-claude-sonnet-4-6}
|
||||
- NAVER_CLIENT_ID=${NAVER_CLIENT_ID:-}
|
||||
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET:-}
|
||||
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
|
||||
- INSTA_DATA_PATH=/app/data
|
||||
- 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}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/blog:/app/data
|
||||
- ${RUNTIME_PATH}/data/insta:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
@@ -136,9 +143,9 @@ services:
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- STOCK_LAB_URL=http://stock-lab:8000
|
||||
- STOCK_URL=http://stock: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_DASHBOARD_URL=${REALESTATE_DASHBOARD_URL:-http://localhost:8080/realestate}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||
@@ -157,9 +164,9 @@ services:
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
|
||||
depends_on:
|
||||
- stock-lab
|
||||
- stock
|
||||
- music-lab
|
||||
- blog-lab
|
||||
- insta-lab
|
||||
- realestate-lab
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
@@ -241,9 +248,15 @@ services:
|
||||
container_name: frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- lotto
|
||||
- stock
|
||||
- music-lab
|
||||
- blog-lab
|
||||
- insta-lab
|
||||
- realestate-lab
|
||||
- agent-office
|
||||
- personal
|
||||
- packs-lab
|
||||
- travel-proxy
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
|
||||
2753
docs/superpowers/plans/2026-05-15-insta-agent-implementation.md
Normal file
1785
docs/superpowers/plans/2026-05-16-insta-trends-implementation.md
Normal file
358
docs/superpowers/specs/2026-05-15-insta-agent-design.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# insta-agent 설계 — blog-lab 폐기, 인스타 카드 피드 파이프라인 신설
|
||||
|
||||
작성일: 2026-05-15
|
||||
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적·배경
|
||||
|
||||
기존 `blog-lab` 서비스(네이버 블로그 마케팅 수익화)를 폐기하고, 인스타그램 프로페셔널 계정에 올릴 카드 형식 피드(1080×1350, 10페이지)를 자동 생산하는 `insta-lab` 서비스로 대체한다.
|
||||
|
||||
핵심 가치 제안:
|
||||
- 매일 경제·심리학·연예 등 카테고리에서 화제 키워드를 자동 발견
|
||||
- 사용자가 키워드 1개를 선택하면 10페이지 카드 카피 + PNG 자동 생성
|
||||
- 텔레그램으로 카드 묶음 미디어 그룹 + 추천 캡션·해시태그 푸시
|
||||
- 사용자는 카드 다운로드 → 인스타 수동 업로드 (Graph API 미사용)
|
||||
|
||||
블로그 발행 자동화의 운영 부담(네이버 SEO, 브랜드커넥트 링크 관리, 커미션 추적)을 제거하고 카드 콘텐츠 생산에 집중한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 스코프
|
||||
|
||||
### 포함
|
||||
|
||||
- 신규 컨테이너 `insta-lab` (포트 18700 재활용)
|
||||
- 신규 에이전트 `insta-agent` (`agent-office/app/agents/insta.py`)
|
||||
- 뉴스 수집 → 키워드 추출 → 카드 카피 생성 → 카드 PNG 렌더 → 텔레그램 푸시 파이프라인
|
||||
- HTML/CSS 카드 템플릿 골격 (사용자가 디자인 직접 수정)
|
||||
- 카드 슬레이트·기사·키워드·자산 5테이블 (`insta.db`)
|
||||
- nginx 라우팅 변경 (`/api/blog-marketing/` 제거 → `/api/insta/`)
|
||||
- CLAUDE.md (workspace + web-backend) 갱신
|
||||
|
||||
### 제외
|
||||
|
||||
- 인스타그램 Graph API 자동 발행 (수동 업로드 사용)
|
||||
- 카드 디자인 비주얼 완성 (사용자가 직접 작업)
|
||||
- blog_marketing.db 데이터 마이그레이션 (clean slate)
|
||||
- 다국어 번역, A/B 테스트, 성과 추적
|
||||
|
||||
---
|
||||
|
||||
## 3. 서비스 구성·폐기 범위
|
||||
|
||||
### 폐기
|
||||
|
||||
| 대상 | 처리 |
|
||||
|------|------|
|
||||
| `blog-lab/` 디렉토리 | git rm 통째로 삭제 |
|
||||
| `blog_marketing.db` | 운영·로컬 모두 삭제 (clean slate) |
|
||||
| `agent-office/app/agents/blog.py` | 삭제 |
|
||||
| `service_proxy.py`의 blog_* 함수 | 삭제 |
|
||||
| `agent-office`의 blog 라우팅·텔레그램 명령 | 삭제 |
|
||||
| docker-compose의 `blog-lab` 서비스 정의 | 교체 |
|
||||
| nginx의 `/api/blog-marketing/` location | 교체 |
|
||||
| 환경변수 `BLOG_DATA_PATH` | 제거 |
|
||||
|
||||
### 신규
|
||||
|
||||
| 대상 | 비고 |
|
||||
|------|------|
|
||||
| `insta-lab/` 디렉토리 | 신규 생성 |
|
||||
| `insta-lab` 컨테이너 (포트 18700) | blog-lab 자리 재활용 |
|
||||
| `agents/insta.py` | 신규 에이전트 |
|
||||
| nginx `/api/insta/` → `insta-lab:8000` | 신규 |
|
||||
| 환경변수 `INSTA_DATA_PATH`, `CARD_TEMPLATE_DIR` | 신규 |
|
||||
|
||||
### 재사용 자산 (코드 패턴 차용)
|
||||
|
||||
- `naver_search.py` — 엔드포인트만 `news.json`으로 교체
|
||||
- `generation_tasks` 테이블 + BackgroundTask 폴링 패턴
|
||||
- `prompt_templates` 테이블 + DB 저장 프롬프트 패턴
|
||||
- agent-office의 텔레그램 인라인 키보드·승인 패턴 (`realestate_message.py` 참고)
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 흐름
|
||||
|
||||
### 일일 사이클
|
||||
|
||||
```
|
||||
[09:30 매일 cron — agent-office 스케줄러]
|
||||
1. 뉴스 수집 ─ 카테고리별 시드 키워드로 NAVER news.json 검색
|
||||
─ 카테고리당 상위 30건 메타 + 본문 일부 → news_articles
|
||||
2. 키워드 추출 ─ 카테고리당 빈도 상위 + Claude Haiku 정제
|
||||
─ trending_keywords (score 내림차순)
|
||||
3. 텔레그램 푸시 ─ 카테고리별 후보 5개씩 인라인 키보드
|
||||
─ 사용자 선택 대기
|
||||
|
||||
[사용자가 텔레그램 인라인 버튼 선택]
|
||||
4. 카피 생성 ─ Claude로 10페이지 카피 (1=훅/커버, 2~9=본문 8장, 10=요약/CTA)
|
||||
─ card_slates 저장 (status='draft')
|
||||
5. 카드 렌더 ─ Jinja → HTML 1080×1350 → Playwright headless 스크린샷 10장
|
||||
─ /app/data/insta_cards/{slate_id}/01.png ~ 10.png
|
||||
6. 텔레그램 ─ 미디어 그룹 10장 + 추천 캡션·해시태그
|
||||
─ 사용자 다운로드 후 인스타 수동 업로드
|
||||
```
|
||||
|
||||
### 자동 모드 (옵션)
|
||||
|
||||
- agent-office의 `agent_config.custom_config.auto_select`(bool) 플래그로 제어
|
||||
- `auto_select=true` 설정 시 키워드 추출 직후 카테고리당 score 1위 키워드를 자동 선택해 4~6 단계까지 즉시 진행
|
||||
- 사용자가 텔레그램에서 결과만 확인 (인라인 후보 푸시 단계 skip)
|
||||
|
||||
---
|
||||
|
||||
## 5. 컴포넌트
|
||||
|
||||
### insta-lab (FastAPI 서비스)
|
||||
|
||||
```
|
||||
insta-lab/
|
||||
├── Dockerfile # python:3.12-slim + playwright install chromium --with-deps
|
||||
├── requirements.txt
|
||||
├── pytest.ini
|
||||
├── tests/
|
||||
└── app/
|
||||
├── main.py # FastAPI 라우터
|
||||
├── config.py # NAVER_*, ANTHROPIC_API_KEY, INSTA_DATA_PATH, CARD_TEMPLATE_DIR
|
||||
├── db.py # 6테이블 init + CRUD
|
||||
├── news_collector.py # 네이버 뉴스 API + 본문 정리
|
||||
├── keyword_extractor.py # 빈도 + LLM 정제
|
||||
├── card_writer.py # Claude 10페이지 카피 생성
|
||||
├── card_renderer.py # Jinja → Playwright 스크린샷
|
||||
└── templates/ # 사용자가 직접 수정 (rsync로 NAS 배포)
|
||||
└── default/
|
||||
└── card.html.j2
|
||||
```
|
||||
|
||||
### agent-office 변경
|
||||
|
||||
```
|
||||
agent-office/app/agents/insta.py (신규)
|
||||
- on_schedule: 09:30 → news collect → keyword extract → 텔레그램 후보 푸시
|
||||
- on_command: extract / render <keyword> / list_categories
|
||||
- on_callback: 텔레그램 inline button "render_<keyword_id>" → 카피·렌더·푸시
|
||||
|
||||
agent-office/app/service_proxy.py
|
||||
- blog_* 함수 모두 제거
|
||||
- insta_* 함수 신규 (collect, extract, list_keywords, create_slate, render_slate, get_slate, get_asset)
|
||||
|
||||
agent-office/app/telegram/agent_registry.py
|
||||
- blog 명령 등록 제거 → insta 명령 등록
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. DB 스키마 (insta.db)
|
||||
|
||||
| 테이블 | 핵심 컬럼 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `news_articles` | id PK, category, title, link UNIQUE, summary, pub_date, fetched_at | 일일 수집 기사 메타 |
|
||||
| `trending_keywords` | id PK, keyword, category, score REAL, articles_count, suggested_at, used INTEGER | 카테고리별 화제 키워드 (used=1이면 이미 슬레이트 생성됨) |
|
||||
| `card_slates` | id PK, keyword, category, status (draft/rendered/sent/failed), cover_copy TEXT, body_copies TEXT(JSON 8개), cta_copy TEXT, suggested_caption TEXT, hashtags TEXT(JSON), created_at | 10페이지 카피 묶음 |
|
||||
| `card_assets` | id PK, slate_id FK→card_slates(id), page_index INTEGER 1~10, file_path, file_hash, created_at | 렌더된 PNG 자산 |
|
||||
| `generation_tasks` | id TEXT PK, type, status, progress, message, result_id INTEGER, error TEXT, params TEXT, created_at, updated_at | blog-lab 패턴 그대로 (collect/extract/write/render 통합) |
|
||||
| `prompt_templates` | id PK, name UNIQUE, description, template TEXT, updated_at | `slate_writer`, `keyword_extractor` 두 개 시드 |
|
||||
|
||||
**인덱스**:
|
||||
- `idx_na_category_fetched` ON news_articles(category, fetched_at DESC)
|
||||
- `idx_tk_score` ON trending_keywords(category, score DESC)
|
||||
- `idx_cs_created` ON card_slates(created_at DESC)
|
||||
- `idx_ca_slate` ON card_assets(slate_id, page_index)
|
||||
|
||||
---
|
||||
|
||||
## 7. 카드 렌더 (Playwright)
|
||||
|
||||
### 템플릿
|
||||
|
||||
`templates/default/card.html.j2` — Jinja 변수:
|
||||
|
||||
| 변수 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `page_type` | str | "cover" / "body" / "cta" |
|
||||
| `headline` | str | 페이지 헤드라인 |
|
||||
| `body` | str | 본문 (markdown-lite 허용 — 줄바꿈 보존) |
|
||||
| `accent_color` | str | hex (예: "#FF5733") |
|
||||
| `page_no` | int | 1~10 |
|
||||
| `total_pages` | int | 10 |
|
||||
|
||||
컨테이너 CSS: `width: 1080px; height: 1350px; overflow: hidden;`
|
||||
|
||||
### 렌더 로직 (card_renderer.py)
|
||||
|
||||
1. Playwright async chromium browser 1회 launch
|
||||
2. browser.new_context(viewport={"width": 1080, "height": 1350}) → page
|
||||
3. 10번 반복:
|
||||
- Jinja 렌더 → temp HTML 파일 저장
|
||||
- page.goto(`file://...`)
|
||||
- page.screenshot(path=f"{page_no:02}.png", omit_background=False)
|
||||
4. browser.close
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-noto-cjk fonts-noto-cjk-extra \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN playwright install chromium --with-deps
|
||||
COPY app ./app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
이미지 사이즈 +500MB 예상. NAS Celeron J4025에서 카드 10장 렌더 ≤ 30초 목표.
|
||||
|
||||
---
|
||||
|
||||
## 8. API (insta-lab)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/insta/status` | 서비스 상태 (NAVER/ANTHROPIC 키 여부) |
|
||||
| POST | `/api/insta/news/collect` | 뉴스 수집 수동 트리거 → BackgroundTask |
|
||||
| GET | `/api/insta/news/articles` | 수집 기사 목록 (category, days 필터) |
|
||||
| POST | `/api/insta/keywords/extract` | 키워드 추출 수동 트리거 → BackgroundTask |
|
||||
| GET | `/api/insta/keywords` | 트렌딩 키워드 (category, used 필터) |
|
||||
| POST | `/api/insta/slates` | 슬레이트 생성 (keyword, category) → BackgroundTask |
|
||||
| GET | `/api/insta/slates` | 슬레이트 목록 |
|
||||
| GET | `/api/insta/slates/{id}` | 슬레이트 상세 (카피 + 자산 경로) |
|
||||
| POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 |
|
||||
| GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) |
|
||||
| DELETE | `/api/insta/slates/{id}` | 삭제 (slate + assets) |
|
||||
| GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 |
|
||||
| GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 조회·수정 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 키워드 추출 알고리즘
|
||||
|
||||
```python
|
||||
def extract_keywords(category: str, articles: list[Article]) -> list[Keyword]:
|
||||
# 1. 빈도 기반 후보 추출
|
||||
# - 명사 추출 (간단: 한글 2~6자 정규식 + 불용어 제거)
|
||||
# - 카테고리 시드 키워드와 코사인 유사도 ≥ 0.3 이상만
|
||||
raw_freq = count_nouns(articles)
|
||||
candidates = top_n(raw_freq, n=20)
|
||||
|
||||
# 2. Claude Haiku로 정제
|
||||
# - 시스템 프롬프트: "{category} 인스타 카드용 키워드"
|
||||
# - 입력: 후보 20개 + 각 후보가 등장한 기사 제목 3개
|
||||
# - 출력 JSON: [{"keyword": str, "score": 0~1, "reason": str}]
|
||||
refined = claude_haiku_refine(category, candidates, articles)
|
||||
|
||||
# 3. score 내림차순 → 상위 5개 trending_keywords로 저장
|
||||
return refined[:5]
|
||||
```
|
||||
|
||||
- `score`는 LLM이 평가한 "카드 콘텐츠 적합도" (호기심 유발성 + 시의성 + 구체성)
|
||||
- 시드 키워드는 `prompt_templates.name='category_seeds'`에서 카테고리별 JSON으로 관리
|
||||
|
||||
---
|
||||
|
||||
## 10. 카드 카피 생성 (slate_writer)
|
||||
|
||||
Claude 호출 1회로 10페이지 카피 생성:
|
||||
|
||||
```
|
||||
시스템 프롬프트 (DB 저장, 사용자가 수정 가능):
|
||||
- 너는 인스타그램 카드 뉴스 카피라이터다.
|
||||
- {category} 카테고리, 키워드: {keyword}
|
||||
- 출력은 JSON 객체:
|
||||
{
|
||||
"cover_copy": {"headline": str, "body": str, "accent_color": "#hex"},
|
||||
"body_copies": [
|
||||
{"headline": str, "body": str},
|
||||
... (8개)
|
||||
],
|
||||
"cta_copy": {"headline": str, "body": str, "cta": str},
|
||||
"suggested_caption": str,
|
||||
"hashtags": ["#tag1", ...]
|
||||
}
|
||||
|
||||
입력:
|
||||
- 키워드 + 관련 기사 제목·요약 5건
|
||||
```
|
||||
|
||||
`accent_color`는 카테고리별 기본값(경제=#0F62FE, 심리학=#A66CFF, 연예=#FF5C8A) 사용, LLM이 더 어울리면 override.
|
||||
|
||||
---
|
||||
|
||||
## 11. 에러 처리
|
||||
|
||||
| 단계 | 실패 시 |
|
||||
|------|---------|
|
||||
| 뉴스 수집 | 카테고리별 try/except, 한 카테고리 빈 결과여도 다른 카테고리 진행. 모두 실패 시 텔레그램 알림 |
|
||||
| 키워드 추출 | LLM 실패 시 빈도 기반 결과만 사용 (degrade). LLM 타임아웃 60s |
|
||||
| 카피 생성 | LLM 실패 시 BackgroundTask `failed`, 텔레그램 알림. JSON 파싱 실패 시 1회 retry |
|
||||
| 카드 렌더 | Playwright 크래시 시 retry 1회. 실패 시 slate.status='failed' + 텔레그램 알림. 일부 페이지만 실패 시 해당 페이지만 재렌더 가능 |
|
||||
| 텔레그램 미디어 그룹 | 텔레그램 API 10MB/장 제한 → PNG quality 90, 평균 < 500KB 예상. 초과 시 압축 후 재시도 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 테스트
|
||||
|
||||
- pytest 단위 테스트:
|
||||
- `news_collector` mocked HTTP, JSON 파싱 검증
|
||||
- `keyword_extractor` 빈도 추출 단위 + Claude mock
|
||||
- `card_writer` Claude mock, JSON 스키마 검증
|
||||
- `card_renderer` 작은 fixture HTML로 PNG 1장 생성 (실제 Playwright 통합 테스트 1건)
|
||||
- agent-office 통합: `agents/insta.py` mocked service_proxy로 on_schedule·on_command·on_callback 분기 검증
|
||||
|
||||
---
|
||||
|
||||
## 13. 운영·환경
|
||||
|
||||
### 환경변수 (insta-lab)
|
||||
|
||||
| 변수 | 기본값 | 설명 |
|
||||
|------|--------|------|
|
||||
| `NAVER_CLIENT_ID` | (필수) | 네이버 검색 API 키 |
|
||||
| `NAVER_CLIENT_SECRET` | (필수) | 네이버 검색 API 시크릿 |
|
||||
| `ANTHROPIC_API_KEY` | (필수) | Claude API 키 |
|
||||
| `INSTA_DATA_PATH` | `./data/insta` | DB + 카드 PNG 저장 경로 |
|
||||
| `CARD_TEMPLATE_DIR` | `/app/app/templates` | HTML/CSS 템플릿 디렉토리 |
|
||||
| `CORS_ALLOW_ORIGINS` | `*` | CORS 설정 |
|
||||
|
||||
### docker-compose.yml 변경
|
||||
|
||||
- `blog-lab` 서비스 블록 → `insta-lab` 서비스 블록 (포트 18700:8000 그대로)
|
||||
- 볼륨: `./data/insta:/app/data/insta`
|
||||
|
||||
### nginx default.conf 변경
|
||||
|
||||
```
|
||||
location /api/blog-marketing/ { # 제거
|
||||
...
|
||||
}
|
||||
|
||||
location /api/insta/ { # 신규
|
||||
proxy_pass http://insta-lab:8000;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### CLAUDE.md 갱신
|
||||
|
||||
- workspace/CLAUDE.md: blog-lab 표 행 제거 → insta-lab 추가, `/api/blog-marketing/` 행 제거 → `/api/insta/` 추가, 컨테이너 이름·역할 업데이트
|
||||
- web-backend/CLAUDE.md: 9.x 섹션 blog-lab 통째로 → insta-lab 섹션, 4·5 표 갱신
|
||||
|
||||
---
|
||||
|
||||
## 14. 완료 정의
|
||||
|
||||
- [ ] blog-lab 디렉토리·DB 삭제, 컨테이너에서 더 이상 빌드 안 됨
|
||||
- [ ] insta-lab 컨테이너 빌드 및 헬스체크 통과
|
||||
- [ ] `POST /api/insta/news/collect` → news_articles에 카테고리당 30건 저장 확인
|
||||
- [ ] `POST /api/insta/keywords/extract` → trending_keywords 카테고리당 5개 저장
|
||||
- [ ] `POST /api/insta/slates` → 카피 생성 + 카드 PNG 10장 렌더 (수동 호출)
|
||||
- [ ] agent-office의 insta-agent 09:30 cron 등록, 텔레그램 인라인 키보드 후보 푸시 작동
|
||||
- [ ] 텔레그램 인라인 버튼 클릭 → 미디어 그룹 10장 발송 성공
|
||||
- [ ] CLAUDE.md 양쪽 갱신 후 커밋
|
||||
- [ ] pytest 전체 통과
|
||||
251
docs/superpowers/specs/2026-05-16-insta-trends-design.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# insta-lab Trends 탭 설계 — 외부 트렌드 수집 + 카테고리 가중치
|
||||
|
||||
작성일: 2026-05-16
|
||||
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||
연관 문서: `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. 목적·배경
|
||||
|
||||
insta-lab 운영 첫 사이클(2026-05-16 머지·배포 완료)에서 다음 두 가지 한계가 드러남:
|
||||
|
||||
1. **키워드 발견 소스가 사용자 시드 키워드에만 의존** — 진짜 "지금 뜨고 있는" 화제를 잡지 못함. 카테고리당 5개 시드를 고정해두고 거기에 매칭되는 기사만 모음.
|
||||
2. **계정 정체성을 시스템이 모름** — 사용자가 "내 인스타 계정은 경제 위주"라고 정해도 시스템은 모든 카테고리를 균등하게 처리.
|
||||
|
||||
이 spec은 두 한계를 해소하기 위해:
|
||||
- 외부 트렌드 소스(NAVER 인기 + Google Trends)를 추가해 "발견" 단계를 보강
|
||||
- 계정 카테고리 가중치 모델을 도입해 자동 추출 알고리즘이 계정 정체성을 반영
|
||||
|
||||
---
|
||||
|
||||
## 2. 스코프
|
||||
|
||||
### 포함
|
||||
|
||||
- 신규 백엔드 모듈 `trend_collector.py` (NAVER 인기 + Google Trends 두 source)
|
||||
- 신규 백엔드 모듈 변경: `keyword_extractor.py`에 가중치 기반 `extract_with_weights()` 추가
|
||||
- DB 마이그레이션: `trending_keywords` 테이블에 `source` 컬럼 추가, `account_preferences` 신규 테이블
|
||||
- 신규 API 4개 (`POST /trends/collect`, `GET /trends`, `GET/PUT /preferences`)
|
||||
- 09:00 매일 cron 추가 (트렌드 수집), 09:30 cron 가중치 적용
|
||||
- 프론트엔드: InstaCards 페이지에 탭 네비게이션 추가, Trends 탭 신규 3개 패널
|
||||
|
||||
### 제외
|
||||
|
||||
- pytrends 외 외부 SaaS 트렌드 API (BuzzSumo 등)
|
||||
- 트렌드 시계열 차트
|
||||
- 카테고리 자동 학습 (사용자 카드 생성 이력에서 선호도 추론)
|
||||
- 트렌드 알림 (특정 키워드 등장 시 push)
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 소스
|
||||
|
||||
### 3-1. NAVER 인기 (source = 'naver_popular')
|
||||
- NAVER news.json API 재사용. 카테고리당 시드 키워드로 `sort=sim` (정확도 정렬 = 인기 시그널) 30건 수집
|
||||
- 응답 기사 묶음에서 빈도어 추출 → 카테고리 매핑 (기존 keyword_extractor의 `_count_nouns` + `_top_candidates` 재사용)
|
||||
- 상위 N개를 `trending_keywords` 테이블에 source='naver_popular'로 저장
|
||||
|
||||
### 3-2. Google Trends (source = 'google_trends')
|
||||
- 라이브러리: `pytrends` (PyPI, MIT)
|
||||
- `TrendReq(hl='ko-KR', tz=540).trending_searches(pn='south_korea')` 호출 → 일일 트렌딩 키워드 리스트
|
||||
- 각 키워드에 대해 Claude Haiku 1회 호출로 카테고리 분류 (`economy` / `psychology` / `celebrity` / 사용자 추가 카테고리 / `uncategorized`)
|
||||
- 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 정규화값
|
||||
|
||||
### 3-3. 통합 저장
|
||||
|
||||
기존 `trending_keywords` 스키마에 한 컬럼 추가:
|
||||
|
||||
```sql
|
||||
ALTER TABLE trending_keywords ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
|
||||
-- 기존 row 모두 'manual'로 마킹됨 (시드 키워드에서 추출된 것)
|
||||
-- 신규 source: 'naver_popular' | 'google_trends'
|
||||
```
|
||||
|
||||
`source`별 추가 인덱스:
|
||||
```sql
|
||||
CREATE INDEX idx_tk_source ON trending_keywords(source, suggested_at DESC);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 카테고리 가중치 모델
|
||||
|
||||
### 4-1. 신규 테이블 `account_preferences`
|
||||
|
||||
```sql
|
||||
CREATE TABLE 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'))
|
||||
);
|
||||
```
|
||||
|
||||
- 초기 시드: `economy=1.0`, `psychology=1.0`, `celebrity=1.0` (균등)
|
||||
- 사용자는 0~10 자유 범위 (UI는 0~100 정수%로 노출, 백엔드에서 0~1 정규화)
|
||||
- 합계 강제 없음. 알고리즘 내부에서 비율 정규화
|
||||
- 카테고리 추가 자유. 단 추가 시 `prompt_templates.category_seeds`에도 시드 키워드 함께 정의해야 자동 추출에 반영됨 (UI에서 안내)
|
||||
|
||||
### 4-2. 가중치 기반 추출 알고리즘
|
||||
|
||||
기존 `keyword_extractor.extract_for_category(category, limit)` 유지. 신규:
|
||||
|
||||
```python
|
||||
def extract_with_weights(weights: dict[str, float], total_limit: int) -> list[Keyword]:
|
||||
"""카테고리 가중치 비율대로 키워드를 분배 추출."""
|
||||
if not weights or sum(weights.values()) == 0:
|
||||
# fallback: 균등 가중치
|
||||
cats = list(DEFAULT_CATEGORY_SEEDS.keys())
|
||||
weights = {c: 1.0 for c in cats}
|
||||
|
||||
total_weight = sum(weights.values())
|
||||
saved = []
|
||||
for category, w in weights.items():
|
||||
if w <= 0:
|
||||
continue
|
||||
per_cat = round(total_limit * w / total_weight)
|
||||
if per_cat <= 0:
|
||||
continue
|
||||
saved.extend(extract_for_category(category, limit=per_cat))
|
||||
return saved
|
||||
```
|
||||
|
||||
- `total_limit` 기본 15 (3 카테고리 × 5 시드 시절 합계와 동일)
|
||||
- weight=0 카테고리는 skip (분류는 유지하되 자동 추출에서 제외하고 싶을 때)
|
||||
|
||||
---
|
||||
|
||||
## 5. API (insta-lab)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/insta/trends/collect` | 두 source 모두 수집 (BackgroundTask) → `{task_id}` |
|
||||
| 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}]}` |
|
||||
| PUT | `/api/insta/preferences` | body `{categories: {economy: 0.6, ...}}` → upsert |
|
||||
|
||||
기존 `/api/insta/keywords`는 source 필터 추가 (`?source=manual` 등). 미지정 시 모든 source 반환 (default behavior 유지).
|
||||
|
||||
---
|
||||
|
||||
## 6. 스케줄러 변경 (agent-office InstaAgent)
|
||||
|
||||
기존:
|
||||
- 09:30 — 키워드 추출 → 텔레그램 푸시
|
||||
|
||||
신규:
|
||||
- **09:00 — 외부 트렌드 수집** (NAVER 인기 + Google Trends) — `_run_insta_trends_collect()` 신규 cron
|
||||
- **09:30 — 키워드 추출** (기존 + 가중치 적용) — InstaAgent가 `get_preferences()` 호출 후 `extract_with_weights()` 사용
|
||||
|
||||
수동 트리거: InstaAgent에 `on_command("collect_trends", {})` 신규 액션. 텔레그램에서 `/insta collect_trends` 슬래시 명령 또는 Insta 페이지 버튼에서 호출.
|
||||
|
||||
---
|
||||
|
||||
## 7. 프론트엔드 변경 (web-ui InstaCards.jsx)
|
||||
|
||||
### 7-1. 탭 네비게이션
|
||||
|
||||
기존 5개 패널을 두 탭으로 재구성:
|
||||
|
||||
| 탭 | 패널 |
|
||||
|----|------|
|
||||
| **Cards** (기본) | Trigger, Trending Keywords, Slates, SlateDetail, PromptEditor (기존 그대로) |
|
||||
| **Trends** (신규) | AccountFocusPanel, ExternalTrendsPanel, PreferenceImpactPanel |
|
||||
|
||||
탭 컴포넌트: `<TabBar>` 단순 buttons (`activeTab` state), URL에 `?tab=trends` 쿼리로 deep-link 지원.
|
||||
|
||||
### 7-2. AccountFocusPanel
|
||||
- 카테고리별 가중치 슬라이더 (0~100 정수%) + 우측 막대 차트 (분포 시각화)
|
||||
- **+ 카테고리 추가** 버튼 → 모달로 카테고리명 + 시드 키워드 N개 입력 (시드는 category_seeds 프롬프트 템플릿에 머지)
|
||||
- **저장** 버튼 → `PUT /preferences` (debounce 1초)
|
||||
|
||||
### 7-3. ExternalTrendsPanel
|
||||
- 상단: **🔄 수동 수집** 버튼 + "마지막 수집: HH:MM" 라벨 + 진행 task box
|
||||
- 두 컬럼 (반응형 → 모바일은 세로):
|
||||
- **🔥 NAVER 인기** — 카테고리별 그룹핑, 각 카드: keyword + score + 카테고리 배지
|
||||
- **🌐 Google Trends** — 단순 리스트, 각 카드: keyword + 카테고리 배지 + traffic
|
||||
- 각 카드 우측에 **🎴** 버튼 → 즉시 `POST /slates` (기존 흐름)
|
||||
- 색상 매핑: economy=#0F62FE, psychology=#A66CFF, celebrity=#FF5C8A, custom=#6B7280
|
||||
|
||||
### 7-4. PreferenceImpactPanel (작은 박스)
|
||||
- "현재 가중치 기준 다음 자동 추출 결과 미리보기: economy 3 / psychology 2 / celebrity 0"
|
||||
- 가중치 슬라이더 변경 시 즉시 클라이언트에서 계산해 갱신
|
||||
- 컴팩트 1줄 표시
|
||||
|
||||
### 7-5. 신규 API 헬퍼 (src/api.js)
|
||||
|
||||
```js
|
||||
export function getInstaTrends({ source, category, days = 1 } = {}) { ... }
|
||||
export function instaCollectTrends() { ... }
|
||||
export function getInstaPreferences() { ... }
|
||||
export function putInstaPreferences(categories) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 에러 처리
|
||||
|
||||
| 상황 | 처리 |
|
||||
|------|------|
|
||||
| pytrends rate limit / 차단 | try/except → 빈 결과로 graceful degrade. NAVER 인기는 정상 수집 |
|
||||
| LLM 분류 실패 | `uncategorized` 카테고리로 폴백, 사용자가 UI에서 수동 재분류 가능 |
|
||||
| 가중치 합계 0 | 균등 가중치 (1/N)로 폴백, 로그 warning |
|
||||
| 카테고리 추가했는데 시드 없음 | 자동 추출에서 자연스럽게 skip (NAVER 검색에 시드 필요), UI에서 "시드 키워드 추가 필요" 경고 |
|
||||
| Google Trends 한국 region 부재 | hl='ko-KR' + pn='south_korea' 명시. 실패 시 빈 결과 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 테스트
|
||||
|
||||
### insta-lab pytest
|
||||
- `test_trend_collector.py` (4): `fetch_naver_popular` mocked, `fetch_google_trends` pytrends mocked, 카테고리 매핑, 캐시 hit
|
||||
- `test_extract_with_weights.py` (3): 균등 가중치, 한쪽 0 가중치, fallback 빈 가중치
|
||||
- `test_preferences_crud.py` (2): GET 기본값, PUT upsert
|
||||
- `test_main_trends.py` (3): 신규 4개 엔드포인트 통합
|
||||
|
||||
### agent-office pytest
|
||||
- `test_insta_agent_trends.py` (2): `on_schedule_trends` mocked, weight-applied extract
|
||||
|
||||
---
|
||||
|
||||
## 10. 마이그레이션 절차
|
||||
|
||||
1. `db.init_db()`에 `ALTER TABLE trending_keywords ADD COLUMN source ...` 추가 — `PRAGMA table_info`로 컬럼 존재 여부 확인 후 idempotent하게 실행
|
||||
2. `account_preferences` 테이블 신규 생성
|
||||
3. 초기 시드: 기존 카테고리 economy/psychology/celebrity 모두 weight=1.0
|
||||
4. 기존 `trending_keywords` row는 자동으로 source='manual' (컬럼 DEFAULT)
|
||||
5. `requirements.txt`에 `pytrends>=4.9` 추가
|
||||
6. 배포 후 사용자가 Trends 탭에서 가중치 조정 (필수 아님, 균등이 디폴트 동작)
|
||||
|
||||
---
|
||||
|
||||
## 11. 운영 영향
|
||||
|
||||
| 항목 | 영향 |
|
||||
|------|------|
|
||||
| Anthropic 토큰 비용 | +미미 (Google Trends 1회당 ~20 키워드 × Haiku 분류 1콜 ≈ 600 토큰/일) |
|
||||
| DB 크기 | +미미 (트렌드 row 일일 ~50개, 카테고리당 30 + Google 20) |
|
||||
| NAS CPU | +낮음 (pytrends + NAVER API 호출만, LLM은 외부) |
|
||||
| 카드 생성 흐름 | 변경 없음. 트렌드는 "발견" 단계만 보강 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 완료 정의
|
||||
|
||||
- [ ] `trending_keywords.source` 컬럼 마이그레이션 적용, 기존 row 모두 'manual'로 표시됨
|
||||
- [ ] `account_preferences` 테이블 생성, 초기 3개 카테고리 weight=1.0
|
||||
- [ ] `POST /api/insta/trends/collect` 호출 시 NAVER 인기 + Google Trends 모두 수집되어 DB 저장
|
||||
- [ ] `GET /api/insta/trends?source=google_trends` 결과 카테고리 분류됨
|
||||
- [ ] `PUT /api/insta/preferences` 후 09:30 cron이 가중치 비율대로 추출
|
||||
- [ ] 09:00 cron 등록, 매일 자동 트렌드 수집
|
||||
- [ ] Insta 페이지에 Cards/Trends 탭 전환 작동
|
||||
- [ ] Trends 탭의 AccountFocusPanel에서 가중치 변경·저장 가능
|
||||
- [ ] ExternalTrendsPanel에서 NAVER 인기 + Google Trends 한 눈에 표시, 각 카드 생성 트리거 작동
|
||||
- [ ] PreferenceImpactPanel 미리보기 갱신
|
||||
- [ ] insta-lab pytest 전체 통과 (기존 21 + 신규 12 = 33)
|
||||
- [ ] agent-office pytest 전체 통과
|
||||
@@ -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 디자인 반영 확인
|
||||
26
insta-lab/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Korean fonts + Chromium runtime deps (Debian 12 / bookworm)
|
||||
# `playwright install --with-deps`를 쓰지 않는 이유: 그 명령은 Ubuntu 패키지명을
|
||||
# 사용해 Debian에서 ttf-ubuntu-font-family / ttf-unifont 등 없는 패키지를 시도
|
||||
# → apt 실패. 대신 Chromium이 실제 필요로 하는 라이브러리만 명시 설치.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-noto-cjk fonts-noto-cjk-extra \
|
||||
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
||||
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
||||
libxfixes3 libxrandr2 libgbm1 libxshmfence1 libpango-1.0-0 \
|
||||
libcairo2 libasound2 libatspi2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY 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
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
108
insta-lab/app/card_renderer.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Jinja → HTML → Playwright headless screenshot."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
from .config import CARDS_DIR, CARD_TEMPLATE_DIR
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_template_dir() -> str:
|
||||
"""Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/."""
|
||||
if os.path.isdir(CARD_TEMPLATE_DIR):
|
||||
return CARD_TEMPLATE_DIR
|
||||
return os.path.join(os.path.dirname(__file__), "templates")
|
||||
|
||||
|
||||
def _env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(_resolve_template_dir()),
|
||||
autoescape=select_autoescape(["html", "j2"]),
|
||||
)
|
||||
|
||||
|
||||
def _slate_dir(slate_id: int) -> str:
|
||||
out = os.path.join(CARDS_DIR, str(slate_id))
|
||||
os.makedirs(out, exist_ok=True)
|
||||
return out
|
||||
|
||||
|
||||
def _build_pages(slate: dict) -> List[dict]:
|
||||
cover = json.loads(slate["cover_copy"] or "{}")
|
||||
bodies = json.loads(slate["body_copies"] or "[]")
|
||||
cta = json.loads(slate["cta_copy"] or "{}")
|
||||
accent = cover.get("accent_color") or "#0F62FE"
|
||||
pages: List[dict] = []
|
||||
pages.append({
|
||||
"page_type": "cover", "page_no": 1, "total_pages": 10,
|
||||
"headline": cover.get("headline", ""), "body": cover.get("body", ""),
|
||||
"accent_color": accent, "cta": "",
|
||||
})
|
||||
for i, b in enumerate(bodies[:8]):
|
||||
pages.append({
|
||||
"page_type": "body", "page_no": i + 2, "total_pages": 10,
|
||||
"headline": b.get("headline", ""), "body": b.get("body", ""),
|
||||
"accent_color": accent, "cta": "",
|
||||
})
|
||||
pages.append({
|
||||
"page_type": "cta", "page_no": 10, "total_pages": 10,
|
||||
"headline": cta.get("headline", ""), "body": cta.get("body", ""),
|
||||
"accent_color": accent, "cta": cta.get("cta", ""),
|
||||
})
|
||||
return pages
|
||||
|
||||
|
||||
async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]:
|
||||
slate = db.get_card_slate(slate_id)
|
||||
if not slate:
|
||||
raise ValueError(f"slate {slate_id} not found")
|
||||
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)
|
||||
pages = _build_pages(slate)
|
||||
out_dir = _slate_dir(slate_id)
|
||||
paths: List[str] = []
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch()
|
||||
try:
|
||||
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
|
||||
page = await ctx.new_page()
|
||||
for spec in pages:
|
||||
html_str = tmpl.render(**spec)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
|
||||
f.write(html_str)
|
||||
html_path = f.name
|
||||
try:
|
||||
await page.goto(f"file://{html_path}", wait_until="networkidle")
|
||||
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
|
||||
await page.screenshot(path=out_path, full_page=False, omit_background=False)
|
||||
with open(out_path, "rb") as fp:
|
||||
file_hash = hashlib.md5(fp.read()).hexdigest()
|
||||
db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash)
|
||||
paths.append(out_path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(html_path)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
await browser.close()
|
||||
return paths
|
||||
100
insta-lab/app/card_writer.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Claude로 10페이지 카드 카피를 한 번에 생성."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_ACCENT_BY_CATEGORY = {
|
||||
"economy": "#0F62FE",
|
||||
"psychology": "#A66CFF",
|
||||
"celebrity": "#FF5C8A",
|
||||
}
|
||||
|
||||
DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다.
|
||||
카테고리: {category}
|
||||
키워드: {keyword}
|
||||
참고 기사:
|
||||
{articles}
|
||||
|
||||
10페이지 인스타 카드용 카피를 다음 JSON 한 객체로만 출력해라 (코드펜스 금지):
|
||||
{{
|
||||
"cover_copy": {{"headline": "<훅 한 줄>", "body": "<서브카피 1~2줄>", "accent_color": "#hex"}},
|
||||
"body_copies": [
|
||||
{{"headline": "<포인트 헤드라인>", "body": "<2~4문장 본문>"}},
|
||||
... (총 8개)
|
||||
],
|
||||
"cta_copy": {{"headline": "<요약 한 줄>", "body": "<마무리 1~2줄>", "cta": "팔로우/저장 등"}},
|
||||
"suggested_caption": "<인스타 캡션 본문>",
|
||||
"hashtags": ["#태그1", "#태그2", ...]
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def _client() -> Anthropic:
|
||||
return Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
|
||||
|
||||
def _strip_codefence(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def _load_prompt() -> str:
|
||||
pt = db.get_prompt_template("slate_writer")
|
||||
if pt and pt.get("template"):
|
||||
return pt["template"]
|
||||
return DEFAULT_PROMPT
|
||||
|
||||
|
||||
def write_slate(keyword: str, category: str,
|
||||
articles: Optional[list] = None) -> int:
|
||||
"""Claude로 10페이지 카피 생성 후 card_slates에 저장. slate_id 반환."""
|
||||
if articles is None:
|
||||
articles = db.list_news_articles(category=category, days=2)
|
||||
article_text = "\n".join(
|
||||
f"- {a['title']}: {a.get('summary', '')[:120]}" for a in articles[:8]
|
||||
) or "(참고 기사 없음)"
|
||||
|
||||
prompt = _load_prompt().format(category=category, keyword=keyword, articles=article_text)
|
||||
msg = _client().messages.create(
|
||||
model=ANTHROPIC_MODEL_SONNET,
|
||||
max_tokens=4000,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
raw = msg.content[0].text
|
||||
cleaned = _strip_codefence(raw)
|
||||
try:
|
||||
data: Dict[str, Any] = json.loads(cleaned)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("slate JSON parse failed: %s", e)
|
||||
raise ValueError(f"Invalid JSON from LLM: {e}") from e
|
||||
|
||||
body_copies = data.get("body_copies") or []
|
||||
if len(body_copies) != 8:
|
||||
raise ValueError(f"body_copies must have 8 items, got {len(body_copies)}")
|
||||
|
||||
cover = data.get("cover_copy") or {}
|
||||
if not cover.get("accent_color"):
|
||||
cover["accent_color"] = DEFAULT_ACCENT_BY_CATEGORY.get(category, "#222831")
|
||||
|
||||
sid = db.add_card_slate({
|
||||
"keyword": keyword,
|
||||
"category": category,
|
||||
"status": "draft",
|
||||
"cover_copy": cover,
|
||||
"body_copies": body_copies,
|
||||
"cta_copy": data.get("cta_copy") or {},
|
||||
"suggested_caption": data.get("suggested_caption") or "",
|
||||
"hashtags": data.get("hashtags") or [],
|
||||
})
|
||||
return sid
|
||||
27
insta-lab/app/config.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
|
||||
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_MODEL_HAIKU = os.getenv("ANTHROPIC_MODEL_HAIKU", "claude-haiku-4-5-20251001")
|
||||
ANTHROPIC_MODEL_SONNET = os.getenv("ANTHROPIC_MODEL_SONNET", "claude-sonnet-4-6")
|
||||
|
||||
INSTA_DATA_PATH = os.getenv("INSTA_DATA_PATH", "/app/data")
|
||||
DB_PATH = os.path.join(INSTA_DATA_PATH, "insta.db")
|
||||
CARDS_DIR = os.path.join(INSTA_DATA_PATH, "insta_cards")
|
||||
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", "http://localhost:3007,http://localhost:8080"
|
||||
)
|
||||
|
||||
NEWS_PER_CATEGORY = int(os.getenv("NEWS_PER_CATEGORY", "30"))
|
||||
KEYWORDS_PER_CATEGORY = int(os.getenv("KEYWORDS_PER_CATEGORY", "5"))
|
||||
|
||||
DEFAULT_CATEGORY_SEEDS = {
|
||||
"economy": ["금리", "인플레이션", "환율", "주식", "부동산"],
|
||||
"psychology": ["심리학", "스트레스", "우울증", "관계", "자존감"],
|
||||
"celebrity": ["연예인", "드라마", "예능", "K-POP", "영화"],
|
||||
}
|
||||
352
insta-lab/app/db.py
Normal file
@@ -0,0 +1,352 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS news_articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
link TEXT NOT NULL UNIQUE,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
pub_date TEXT,
|
||||
fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_na_category_fetched ON news_articles(category, fetched_at DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS trending_keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
score REAL NOT NULL DEFAULT 0,
|
||||
articles_count INTEGER NOT NULL DEFAULT 0,
|
||||
suggested_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tk_score ON trending_keywords(category, score DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS card_slates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
cover_copy TEXT NOT NULL DEFAULT '{}',
|
||||
body_copies TEXT NOT NULL DEFAULT '[]',
|
||||
cta_copy TEXT NOT NULL DEFAULT '{}',
|
||||
suggested_caption TEXT NOT NULL DEFAULT '',
|
||||
hashtags TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_cs_created ON card_slates(created_at DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS card_assets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slate_id INTEGER NOT NULL REFERENCES card_slates(id) ON DELETE CASCADE,
|
||||
page_index INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_hash TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE (slate_id, page_index)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ca_slate ON card_assets(slate_id, page_index)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS generation_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
result_id INTEGER,
|
||||
error TEXT,
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gt_created ON generation_tasks(created_at DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prompt_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
template TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# 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 ────────────────────────────────────────────────
|
||||
def add_news_article(row: Dict[str, Any]) -> int:
|
||||
with _conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO news_articles(category, title, link, summary, pub_date) VALUES(?,?,?,?,?)",
|
||||
(row["category"], row["title"], row["link"], row.get("summary", ""), row.get("pub_date")),
|
||||
)
|
||||
return cur.lastrowid
|
||||
except sqlite3.IntegrityError:
|
||||
existing = conn.execute("SELECT id FROM news_articles WHERE link=?", (row["link"],)).fetchone()
|
||||
return existing["id"] if existing else 0
|
||||
|
||||
|
||||
def list_news_articles(category: Optional[str] = None, days: int = 1) -> List[Dict[str, Any]]:
|
||||
sql = "SELECT * FROM news_articles WHERE fetched_at >= datetime('now', ?)"
|
||||
params: List[Any] = [f"-{int(days)} days"]
|
||||
if category:
|
||||
sql += " AND category=?"
|
||||
params.append(category)
|
||||
sql += " ORDER BY fetched_at DESC"
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── trending_keywords ───────────────────────────────────────────
|
||||
def add_trending_keyword(row: Dict[str, Any]) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"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.get("source", "manual"),
|
||||
),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def list_trending_keywords(category: Optional[str] = None, used: Optional[bool] = None) -> List[Dict[str, Any]]:
|
||||
sql = "SELECT * FROM trending_keywords WHERE 1=1"
|
||||
params: List[Any] = []
|
||||
if category:
|
||||
sql += " AND category=?"
|
||||
params.append(category)
|
||||
if used is not None:
|
||||
sql += " AND used=?"
|
||||
params.append(1 if used else 0)
|
||||
sql += " ORDER BY score DESC, suggested_at DESC"
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def mark_keyword_used(keyword_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("UPDATE trending_keywords SET used=1 WHERE id=?", (keyword_id,))
|
||||
|
||||
|
||||
def get_trending_keyword(keyword_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM trending_keywords WHERE id=?", (keyword_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── card_slates ─────────────────────────────────────────────────
|
||||
def add_card_slate(row: Dict[str, Any]) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("""
|
||||
INSERT INTO card_slates(keyword, category, status, cover_copy, body_copies, cta_copy,
|
||||
suggested_caption, hashtags)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
row["keyword"], row["category"], row.get("status", "draft"),
|
||||
json.dumps(row.get("cover_copy", {}), ensure_ascii=False),
|
||||
json.dumps(row.get("body_copies", []), ensure_ascii=False),
|
||||
json.dumps(row.get("cta_copy", {}), ensure_ascii=False),
|
||||
row.get("suggested_caption", ""),
|
||||
json.dumps(row.get("hashtags", []), ensure_ascii=False),
|
||||
))
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_slate_status(slate_id: int, status: str) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE card_slates SET status=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
|
||||
(status, slate_id),
|
||||
)
|
||||
|
||||
|
||||
def get_card_slate(slate_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM card_slates WHERE id=?", (slate_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_card_slates(limit: int = 50) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM card_slates ORDER BY created_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_card_slate(slate_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM card_slates WHERE id=?", (slate_id,))
|
||||
|
||||
|
||||
# ── card_assets ─────────────────────────────────────────────────
|
||||
def add_card_asset(slate_id: int, page_index: int, file_path: str, file_hash: str = "") -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("""
|
||||
INSERT INTO card_assets(slate_id, page_index, file_path, file_hash)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(slate_id, page_index) DO UPDATE SET
|
||||
file_path=excluded.file_path, file_hash=excluded.file_hash
|
||||
""", (slate_id, page_index, file_path, file_hash))
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def list_card_assets(slate_id: int) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM card_assets WHERE slate_id=? ORDER BY page_index ASC",
|
||||
(slate_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── generation_tasks ────────────────────────────────────────────
|
||||
def create_task(task_type: str, params: Dict[str, Any]) -> str:
|
||||
tid = uuid.uuid4().hex
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO generation_tasks(id, type, params) VALUES(?,?,?)",
|
||||
(tid, task_type, json.dumps(params, ensure_ascii=False)),
|
||||
)
|
||||
return tid
|
||||
|
||||
|
||||
def update_task(task_id: str, status: str, progress: int = 0, message: str = "",
|
||||
result_id: Optional[int] = None, error: Optional[str] = None) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
UPDATE generation_tasks
|
||||
SET status=?, progress=?, message=?, result_id=?, error=?,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id=?
|
||||
""", (status, progress, message, result_id, error, task_id))
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM generation_tasks WHERE id=?", (task_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── prompt_templates ────────────────────────────────────────────
|
||||
def upsert_prompt_template(name: str, template: str, description: str = "") -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO prompt_templates(name, description, template)
|
||||
VALUES(?,?,?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
template=excluded.template,
|
||||
description=excluded.description,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
""", (name, description, template))
|
||||
|
||||
|
||||
def get_prompt_template(name: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM prompt_templates WHERE name=?", (name,)).fetchone()
|
||||
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()
|
||||
102
insta-lab/app/keyword_extractor.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""키워드 추출 — 한글 명사 빈도 + Claude Haiku 정제."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU, KEYWORDS_PER_CATEGORY
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NOUN_RE = re.compile(r"[가-힣]{2,6}")
|
||||
_STOPWORDS = {
|
||||
"있다", "없다", "이다", "되다", "그리고", "하지만", "통해", "위해", "오늘", "이번",
|
||||
"지난", "관련", "대해", "또한", "다만", "한편", "최근", "앞서", "현재", "진행",
|
||||
"발생", "결과", "이상", "이하", "여러", "다양", "방법", "경우", "이유", "필요",
|
||||
}
|
||||
|
||||
|
||||
def _count_nouns(text: str) -> Dict[str, int]:
|
||||
tokens = _NOUN_RE.findall(text or "")
|
||||
return Counter(tokens)
|
||||
|
||||
|
||||
def _top_candidates(counts: Dict[str, int], n: int = 20) -> List[tuple]:
|
||||
filtered = [(k, c) for k, c in counts.items() if k not in _STOPWORDS]
|
||||
return sorted(filtered, key=lambda x: x[1], reverse=True)[:n]
|
||||
|
||||
|
||||
def _refine_with_llm(category: str, candidates: List[tuple], articles: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Claude Haiku로 후보 정제. JSON 리스트 [{keyword, score(0~1), reason}] 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
return [{"keyword": k, "score": min(1.0, c / 10), "reason": "freq"} for k, c in candidates[:KEYWORDS_PER_CATEGORY]]
|
||||
|
||||
client = Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
titles = [a["title"] for a in articles[:15]]
|
||||
prompt = f"""너는 인스타그램 카드 뉴스 큐레이터다.
|
||||
카테고리: {category}
|
||||
빈도 상위 후보: {[k for k, _ in candidates]}
|
||||
관련 기사 제목 일부:
|
||||
{chr(10).join('- ' + t for t in titles)}
|
||||
|
||||
이 후보 중에서 인스타 카드 콘텐츠로 적합한 키워드를 score 내림차순으로 최대 {KEYWORDS_PER_CATEGORY}개 골라.
|
||||
출력 형식 (JSON 배열만):
|
||||
[{{"keyword": "...", "score": 0.0~1.0, "reason": "..."}}]
|
||||
"""
|
||||
msg = client.messages.create(
|
||||
model=ANTHROPIC_MODEL_HAIKU,
|
||||
max_tokens=600,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
text = msg.content[0].text.strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
logger.warning("LLM refine JSON parse failed, falling back to freq")
|
||||
return [{"keyword": k, "score": min(1.0, c / 10), "reason": "freq-fallback"} for k, c in candidates[:KEYWORDS_PER_CATEGORY]]
|
||||
|
||||
|
||||
def extract_for_category(category: str, limit: int = KEYWORDS_PER_CATEGORY) -> List[Dict[str, Any]]:
|
||||
"""카테고리 기사들에서 키워드를 뽑아 DB에 저장하고 결과 반환."""
|
||||
articles = db.list_news_articles(category=category, days=2)
|
||||
text_blob = "\n".join((a["title"] + " " + a.get("summary", "")) for a in articles)
|
||||
counts = _count_nouns(text_blob)
|
||||
candidates = _top_candidates(counts, n=20)
|
||||
refined = _refine_with_llm(category, candidates, articles)[:limit]
|
||||
|
||||
saved: List[Dict[str, Any]] = []
|
||||
for kw in refined:
|
||||
kid = db.add_trending_keyword({
|
||||
"keyword": kw["keyword"],
|
||||
"category": category,
|
||||
"score": float(kw.get("score", 0.0)),
|
||||
"articles_count": sum(1 for a in articles if kw["keyword"] in a["title"]),
|
||||
})
|
||||
saved.append({"id": kid, **kw, "category": category})
|
||||
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
|
||||
306
insta-lab/app/main.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""FastAPI entrypoint for insta-lab."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Body, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .config import (
|
||||
CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY,
|
||||
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, trend_collector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in CORS_ALLOW_ORIGINS.split(",")],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
os.makedirs(INSTA_DATA_PATH, exist_ok=True)
|
||||
db.init_db()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/insta/status")
|
||||
def status():
|
||||
return {
|
||||
"ok": True,
|
||||
"naver_api": bool(NAVER_CLIENT_ID),
|
||||
"anthropic_api": bool(ANTHROPIC_API_KEY),
|
||||
}
|
||||
|
||||
|
||||
# ── News ─────────────────────────────────────────────────────────
|
||||
class CollectRequest(BaseModel):
|
||||
categories: Optional[list[str]] = None
|
||||
|
||||
|
||||
def _seeds_for(category: str) -> list[str]:
|
||||
pt = db.get_prompt_template("category_seeds")
|
||||
if pt and pt.get("template"):
|
||||
try:
|
||||
data = json.loads(pt["template"])
|
||||
if category in data:
|
||||
return list(data[category])
|
||||
except Exception:
|
||||
pass
|
||||
return list(DEFAULT_CATEGORY_SEEDS.get(category, []))
|
||||
|
||||
|
||||
async def _bg_collect(task_id: str, categories: list[str]):
|
||||
try:
|
||||
db.update_task(task_id, "processing", 10, "수집 중")
|
||||
total = 0
|
||||
for cat in categories:
|
||||
seeds = _seeds_for(cat)
|
||||
if not seeds:
|
||||
continue
|
||||
total += news_collector.collect_for_category(cat, seeds)
|
||||
db.update_task(task_id, "succeeded", 100, f"{total}건 수집", result_id=total)
|
||||
except Exception as e:
|
||||
logger.exception("collect failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/insta/news/collect")
|
||||
def collect_news(req: CollectRequest, bg: BackgroundTasks):
|
||||
cats = req.categories or list(DEFAULT_CATEGORY_SEEDS.keys())
|
||||
tid = db.create_task("news_collect", {"categories": cats})
|
||||
bg.add_task(_bg_collect, tid, cats)
|
||||
return {"task_id": tid, "categories": cats}
|
||||
|
||||
|
||||
@app.get("/api/insta/news/articles")
|
||||
def list_articles(category: Optional[str] = None, days: int = Query(7, ge=1, le=90)):
|
||||
return {"items": db.list_news_articles(category=category, days=days)}
|
||||
|
||||
|
||||
# ── Keywords ─────────────────────────────────────────────────────
|
||||
class ExtractRequest(BaseModel):
|
||||
categories: Optional[list[str]] = None
|
||||
|
||||
|
||||
async def _bg_extract(task_id: str, categories: Optional[list[str]] = None):
|
||||
try:
|
||||
db.update_task(task_id, "processing", 10, "추출 중")
|
||||
prefs_rows = db.get_preferences()
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.exception("extract failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/insta/keywords/extract")
|
||||
def extract_keywords(req: ExtractRequest, bg: BackgroundTasks):
|
||||
cats = req.categories or list(DEFAULT_CATEGORY_SEEDS.keys())
|
||||
tid = db.create_task("keyword_extract", {"categories": cats})
|
||||
bg.add_task(_bg_extract, tid, cats)
|
||||
return {"task_id": tid, "categories": cats}
|
||||
|
||||
|
||||
@app.get("/api/insta/keywords")
|
||||
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)}
|
||||
|
||||
|
||||
# ── Slates ───────────────────────────────────────────────────────
|
||||
class SlateRequest(BaseModel):
|
||||
keyword: str
|
||||
category: str
|
||||
keyword_id: Optional[int] = None
|
||||
|
||||
|
||||
async def _bg_create_slate(task_id: str, keyword: str, category: str, keyword_id: Optional[int]):
|
||||
try:
|
||||
db.update_task(task_id, "processing", 30, "카피 생성 중")
|
||||
sid = card_writer.write_slate(keyword=keyword, category=category)
|
||||
db.update_task(task_id, "processing", 70, "카드 렌더 중")
|
||||
await card_renderer.render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
||||
db.update_slate_status(sid, "rendered")
|
||||
if keyword_id:
|
||||
db.mark_keyword_used(keyword_id)
|
||||
db.update_task(task_id, "succeeded", 100, "완료", result_id=sid)
|
||||
except Exception as e:
|
||||
logger.exception("create slate failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/insta/slates")
|
||||
def create_slate(req: SlateRequest, bg: BackgroundTasks):
|
||||
tid = db.create_task("slate_create", req.dict())
|
||||
bg.add_task(_bg_create_slate, tid, req.keyword, req.category, req.keyword_id)
|
||||
return {"task_id": tid}
|
||||
|
||||
|
||||
@app.get("/api/insta/slates")
|
||||
def list_slates(limit: int = Query(50, ge=1, le=500)):
|
||||
return {"items": db.list_card_slates(limit=limit)}
|
||||
|
||||
|
||||
@app.get("/api/insta/slates/{slate_id}")
|
||||
def get_slate(slate_id: int):
|
||||
s = db.get_card_slate(slate_id)
|
||||
if not s:
|
||||
raise HTTPException(404, "slate not found")
|
||||
s["assets"] = db.list_card_assets(slate_id)
|
||||
for k in ("cover_copy", "body_copies", "cta_copy", "hashtags"):
|
||||
if isinstance(s.get(k), str):
|
||||
try:
|
||||
s[k] = json.loads(s[k])
|
||||
except Exception:
|
||||
pass
|
||||
return s
|
||||
|
||||
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
try:
|
||||
db.update_task(task_id, "processing", 30, "재렌더 중")
|
||||
await card_renderer.render_slate(slate_id, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
||||
db.update_slate_status(slate_id, "rendered")
|
||||
db.update_task(task_id, "succeeded", 100, "완료", result_id=slate_id)
|
||||
except Exception as e:
|
||||
logger.exception("render failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/insta/slates/{slate_id}/render")
|
||||
def render_slate_endpoint(slate_id: int, bg: BackgroundTasks):
|
||||
if not db.get_card_slate(slate_id):
|
||||
raise HTTPException(404, "slate not found")
|
||||
tid = db.create_task("slate_render", {"slate_id": slate_id})
|
||||
bg.add_task(_bg_render, tid, slate_id)
|
||||
return {"task_id": tid}
|
||||
|
||||
|
||||
@app.get("/api/insta/slates/{slate_id}/assets/{page}")
|
||||
def get_asset(slate_id: int, page: int):
|
||||
if not (1 <= page <= 10):
|
||||
raise HTTPException(400, "page must be 1..10")
|
||||
assets = db.list_card_assets(slate_id)
|
||||
match = next((a for a in assets if a["page_index"] == page), None)
|
||||
if not match:
|
||||
raise HTTPException(404, "asset not found")
|
||||
return FileResponse(match["file_path"], media_type="image/png")
|
||||
|
||||
|
||||
@app.delete("/api/insta/slates/{slate_id}")
|
||||
def delete_slate(slate_id: int):
|
||||
if not db.get_card_slate(slate_id):
|
||||
raise HTTPException(404)
|
||||
for a in db.list_card_assets(slate_id):
|
||||
try:
|
||||
os.unlink(a["file_path"])
|
||||
except OSError:
|
||||
pass
|
||||
db.delete_card_slate(slate_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Tasks ────────────────────────────────────────────────────────
|
||||
@app.get("/api/insta/tasks/{task_id}")
|
||||
def get_task_status(task_id: str):
|
||||
t = db.get_task(task_id)
|
||||
if not t:
|
||||
raise HTTPException(404)
|
||||
return t
|
||||
|
||||
|
||||
# ── Prompt Templates ─────────────────────────────────────────────
|
||||
class TemplateBody(BaseModel):
|
||||
template: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
@app.get("/api/insta/templates/prompts/{name}")
|
||||
def get_prompt(name: str):
|
||||
pt = db.get_prompt_template(name)
|
||||
if not pt:
|
||||
raise HTTPException(404)
|
||||
return pt
|
||||
|
||||
|
||||
@app.put("/api/insta/templates/prompts/{name}")
|
||||
def upsert_prompt(name: str, body: TemplateBody):
|
||||
db.upsert_prompt_template(name, body.template, body.description)
|
||||
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()}
|
||||
82
insta-lab/app/news_collector.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""NAVER 뉴스 검색 API 연동 — 카테고리별 시드 키워드로 일일 수집."""
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .config import NAVER_CLIENT_ID, NAVER_CLIENT_SECRET, NEWS_PER_CATEGORY
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NEWS_URL = "https://openapi.naver.com/v1/search/news.json"
|
||||
_HEADERS = {
|
||||
"X-Naver-Client-Id": NAVER_CLIENT_ID,
|
||||
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
|
||||
}
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
no_tag = _TAG_RE.sub("", text)
|
||||
return html.unescape(no_tag).strip()
|
||||
|
||||
|
||||
def search_news(keyword: str, display: int = 30, sort: str = "date") -> List[Dict[str, Any]]:
|
||||
"""NAVER news.json 단일 호출.
|
||||
|
||||
Returns: list of {title, link, summary, pub_date}
|
||||
"""
|
||||
resp = requests.get(
|
||||
NEWS_URL,
|
||||
headers=_HEADERS,
|
||||
params={"query": keyword, "display": display, "sort": sort},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [
|
||||
{
|
||||
"title": _clean(item.get("title", "")),
|
||||
"link": item.get("link") or item.get("originallink", ""),
|
||||
"summary": _clean(item.get("description", "")),
|
||||
"pub_date": item.get("pubDate", ""),
|
||||
}
|
||||
for item in data.get("items", [])
|
||||
]
|
||||
|
||||
|
||||
def collect_for_category(category: str,
|
||||
seed_keywords: List[str],
|
||||
per_keyword: Optional[int] = None) -> int:
|
||||
"""카테고리에 대해 시드 키워드 각각으로 검색 후 DB에 삽입.
|
||||
UNIQUE(link)가 중복 삽입을 막음. 시도된 기사 수(중복 포함) 반환.
|
||||
"""
|
||||
per_kw = per_keyword if per_keyword is not None else max(1, NEWS_PER_CATEGORY // max(1, len(seed_keywords)))
|
||||
seen_links = set()
|
||||
attempted = 0
|
||||
for kw in seed_keywords:
|
||||
try:
|
||||
items = search_news(kw, display=per_kw)
|
||||
except Exception as e:
|
||||
logger.warning("search_news failed kw=%s err=%s", kw, e)
|
||||
continue
|
||||
for item in items:
|
||||
link = item["link"]
|
||||
if not link or link in seen_links:
|
||||
continue
|
||||
seen_links.add(link)
|
||||
db.add_news_article({
|
||||
"category": category,
|
||||
"title": item["title"],
|
||||
"link": link,
|
||||
"summary": item["summary"],
|
||||
"pub_date": item["pub_date"],
|
||||
})
|
||||
attempted += 1
|
||||
return attempted
|
||||
55
insta-lab/app/templates/default/card.html.j2
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700;900&display=swap');
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 1080px; height: 1350px;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background: #F7F7FA; color: #14171A;
|
||||
}
|
||||
.card {
|
||||
width: 1080px; height: 1350px;
|
||||
padding: 80px 72px;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
background: linear-gradient(180deg, #FFFFFF 0%, #F7F7FA 100%);
|
||||
border-top: 16px solid {{ accent_color }};
|
||||
}
|
||||
.badge {
|
||||
display: inline-block; padding: 8px 20px; border-radius: 999px;
|
||||
background: {{ accent_color }}; color: #fff;
|
||||
font-size: 28px; font-weight: 700; letter-spacing: -0.02em;
|
||||
}
|
||||
.headline {
|
||||
font-size: {{ 96 if page_type == 'cover' else 72 }}px;
|
||||
font-weight: 900; line-height: 1.15; letter-spacing: -0.04em;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.body {
|
||||
font-size: 40px; font-weight: 400; line-height: 1.55;
|
||||
margin-top: 40px; color: #2A2F35;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 28px; color: #6B7280; font-weight: 500;
|
||||
}
|
||||
.cta { font-weight: 700; color: {{ accent_color }}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div>
|
||||
<span class="badge">{{ page_type|upper }}</span>
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="body">{{ body }}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<span>{{ page_no }} / {{ total_pages }}</span>
|
||||
{% if cta %}<span class="cta">{{ cta }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
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}
|
||||
@@ -1,6 +1,9 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
requests==2.32.3
|
||||
anthropic==0.52.0
|
||||
beautifulsoup4>=4.12
|
||||
httpx>=0.27
|
||||
anthropic==0.52.0
|
||||
jinja2>=3.1.4
|
||||
playwright==1.48.0
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
0
insta-lab/tests/__init__.py
Normal file
59
insta-lab/tests/test_card_renderer.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as db_module
|
||||
from app import card_renderer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db_and_dirs(monkeypatch, tmp_path):
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||
monkeypatch.setattr(card_renderer, "CARDS_DIR", str(tmp_path / "cards"))
|
||||
db_module.init_db()
|
||||
yield path
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _seed_slate() -> int:
|
||||
return db_module.add_card_slate({
|
||||
"keyword": "테스트",
|
||||
"category": "economy",
|
||||
"status": "draft",
|
||||
"cover_copy": {"headline": "커버 헤드라인", "body": "서브카피", "accent_color": "#0F62FE"},
|
||||
"body_copies": [{"headline": f"본문 {i+1}", "body": f"내용 {i+1}"} for i in range(8)],
|
||||
"cta_copy": {"headline": "마무리", "body": "감사합니다", "cta": "팔로우"},
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_slate_produces_ten_pngs(tmp_db_and_dirs):
|
||||
sid = _seed_slate()
|
||||
paths = await card_renderer.render_slate(sid)
|
||||
assert len(paths) == 10
|
||||
for p in paths:
|
||||
assert os.path.exists(p)
|
||||
assert os.path.getsize(p) > 1000 # > 1 KB sanity
|
||||
db_module.update_slate_status(sid, "rendered")
|
||||
assets = db_module.list_card_assets(sid)
|
||||
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
|
||||
75
insta-lab/tests/test_card_writer.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as db_module
|
||||
from app import card_writer
|
||||
|
||||
|
||||
@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
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
SAMPLE_LLM_JSON = {
|
||||
"cover_copy": {"headline": "금리 인상 단행", "body": "왜 지금?", "accent_color": "#0F62FE"},
|
||||
"body_copies": [
|
||||
{"headline": f"포인트 {i+1}", "body": f"본문 {i+1}"} for i in range(8)
|
||||
],
|
||||
"cta_copy": {"headline": "정리", "body": "바로 확인", "cta": "팔로우"},
|
||||
"suggested_caption": "금리에 대해 알아보자",
|
||||
"hashtags": ["#금리", "#경제"],
|
||||
}
|
||||
|
||||
|
||||
def _fake_messages_create(*_args, **_kwargs):
|
||||
msg = MagicMock()
|
||||
block = MagicMock()
|
||||
block.text = json.dumps(SAMPLE_LLM_JSON, ensure_ascii=False)
|
||||
msg.content = [block]
|
||||
return msg
|
||||
|
||||
|
||||
def test_write_slate_persists_full_payload(tmp_db, monkeypatch):
|
||||
db_module.add_news_article({
|
||||
"category": "economy", "title": "기준금리 인상 단행",
|
||||
"link": "https://example.com/1", "summary": "한국은행 발표",
|
||||
})
|
||||
fake_client = MagicMock()
|
||||
fake_client.messages.create = _fake_messages_create
|
||||
monkeypatch.setattr(card_writer, "_client", lambda: fake_client)
|
||||
|
||||
sid = card_writer.write_slate(keyword="기준금리", category="economy")
|
||||
slate = db_module.get_card_slate(sid)
|
||||
assert slate["status"] == "draft"
|
||||
body_copies = json.loads(slate["body_copies"])
|
||||
assert len(body_copies) == 8
|
||||
assert body_copies[0]["headline"] == "포인트 1"
|
||||
assert json.loads(slate["cover_copy"])["accent_color"] == "#0F62FE"
|
||||
|
||||
|
||||
def test_write_slate_raises_on_invalid_json(tmp_db, monkeypatch):
|
||||
fake_client = MagicMock()
|
||||
bad_msg = MagicMock()
|
||||
bad_block = MagicMock()
|
||||
bad_block.text = "not json"
|
||||
bad_msg.content = [bad_block]
|
||||
fake_client.messages.create.return_value = bad_msg
|
||||
monkeypatch.setattr(card_writer, "_client", lambda: fake_client)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
card_writer.write_slate(keyword="x", category="economy")
|
||||
97
insta-lab/tests/test_db.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import os
|
||||
import json
|
||||
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
|
||||
# Close all SQLite WAL files before removal (needed on Windows)
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def test_init_db_creates_seven_tables(tmp_db):
|
||||
with db_module._conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
).fetchall()
|
||||
names = sorted(r[0] for r in rows if not r[0].startswith("sqlite_"))
|
||||
assert names == sorted([
|
||||
"news_articles", "trending_keywords", "card_slates",
|
||||
"card_assets", "generation_tasks", "prompt_templates",
|
||||
"account_preferences",
|
||||
])
|
||||
|
||||
|
||||
def test_news_article_roundtrip(tmp_db):
|
||||
aid = db_module.add_news_article({
|
||||
"category": "economy",
|
||||
"title": "금리 인상 발표",
|
||||
"link": "https://example.com/1",
|
||||
"summary": "한국은행이 기준금리를 인상했다.",
|
||||
"pub_date": "2026-05-15T08:00:00",
|
||||
})
|
||||
assert isinstance(aid, int)
|
||||
rows = db_module.list_news_articles(category="economy", days=7)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["title"] == "금리 인상 발표"
|
||||
|
||||
|
||||
def test_trending_keyword_roundtrip(tmp_db):
|
||||
kid = db_module.add_trending_keyword({
|
||||
"keyword": "기준금리",
|
||||
"category": "economy",
|
||||
"score": 0.87,
|
||||
"articles_count": 12,
|
||||
})
|
||||
assert isinstance(kid, int)
|
||||
items = db_module.list_trending_keywords(category="economy", used=False)
|
||||
assert items[0]["score"] == pytest.approx(0.87)
|
||||
|
||||
|
||||
def test_card_slate_with_assets(tmp_db):
|
||||
sid = db_module.add_card_slate({
|
||||
"keyword": "기준금리",
|
||||
"category": "economy",
|
||||
"cover_copy": {"headline": "금리 인상", "body": "왜?", "accent_color": "#0F62FE"},
|
||||
"body_copies": [{"headline": f"H{i}", "body": f"B{i}"} for i in range(8)],
|
||||
"cta_copy": {"headline": "정리", "body": "바로 확인", "cta": "팔로우"},
|
||||
"suggested_caption": "금리에 대해 알아보자",
|
||||
"hashtags": ["#금리", "#경제"],
|
||||
})
|
||||
db_module.add_card_asset(sid, page_index=1, file_path="/tmp/01.png", file_hash="abc")
|
||||
slate = db_module.get_card_slate(sid)
|
||||
assert slate["status"] == "draft"
|
||||
assert json.loads(slate["body_copies"])[0]["headline"] == "H0"
|
||||
assets = db_module.list_card_assets(sid)
|
||||
assert assets[0]["page_index"] == 1
|
||||
|
||||
|
||||
def test_generation_task_lifecycle(tmp_db):
|
||||
tid = db_module.create_task("collect", {"category": "economy"})
|
||||
db_module.update_task(tid, status="processing", progress=50, message="..")
|
||||
db_module.update_task(tid, status="succeeded", progress=100, message="ok", result_id=123)
|
||||
t = db_module.get_task(tid)
|
||||
assert t["status"] == "succeeded"
|
||||
assert t["result_id"] == 123
|
||||
|
||||
|
||||
def test_prompt_template_upsert(tmp_db):
|
||||
db_module.upsert_prompt_template("slate_writer", "v1 template", "writer")
|
||||
db_module.upsert_prompt_template("slate_writer", "v2 template", "writer")
|
||||
pt = db_module.get_prompt_template("slate_writer")
|
||||
assert pt["template"] == "v2 template"
|
||||
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())
|
||||
65
insta-lab/tests/test_keyword_extractor.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
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
|
||||
# Windows-safe cleanup: close handles + remove sidecars
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def test_count_nouns_extracts_korean_nouns():
|
||||
text = "기준금리 인상으로 환율 급등. 기준금리 추가 인상 가능성"
|
||||
counts = keyword_extractor._count_nouns(text)
|
||||
assert counts["기준금리"] == 2
|
||||
assert counts["환율"] == 1
|
||||
|
||||
|
||||
def test_top_candidates_filters_stopwords():
|
||||
counts = {"기준금리": 5, "있다": 7, "환율": 3, "그리고": 4}
|
||||
top = keyword_extractor._top_candidates(counts, n=10)
|
||||
keywords = [k for k, _ in top]
|
||||
assert "있다" not in keywords
|
||||
assert "그리고" not in keywords
|
||||
assert "기준금리" in keywords
|
||||
|
||||
|
||||
def test_extract_for_category_persists(tmp_db):
|
||||
# seed articles
|
||||
for i in range(3):
|
||||
db_module.add_news_article({
|
||||
"category": "economy",
|
||||
"title": f"기준금리 인상 {i}",
|
||||
"link": f"https://example.com/{i}",
|
||||
"summary": "환율도 영향",
|
||||
})
|
||||
|
||||
# mock LLM refinement
|
||||
fake_refined = [
|
||||
{"keyword": "기준금리", "score": 0.92, "reason": "핵심 금융 이슈"},
|
||||
{"keyword": "환율", "score": 0.71, "reason": "시장 영향"},
|
||||
]
|
||||
with patch.object(keyword_extractor, "_refine_with_llm", return_value=fake_refined):
|
||||
kws = keyword_extractor.extract_for_category("economy", limit=2)
|
||||
|
||||
assert len(kws) == 2
|
||||
assert kws[0]["keyword"] == "기준금리"
|
||||
persisted = db_module.list_trending_keywords(category="economy")
|
||||
assert {p["keyword"] for p in persisted} == {"기준금리", "환율"}
|
||||
91
insta-lab/tests/test_main.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
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
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def test_health(client):
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
|
||||
|
||||
def test_status_endpoint(client):
|
||||
resp = client.get("/api/insta/status")
|
||||
assert resp.status_code == 200
|
||||
j = resp.json()
|
||||
assert "naver_api" in j and "anthropic_api" in j
|
||||
|
||||
|
||||
def test_news_articles_listing(client):
|
||||
db_module.add_news_article({
|
||||
"category": "economy", "title": "T1", "link": "https://x/1", "summary": "S",
|
||||
})
|
||||
resp = client.get("/api/insta/news/articles?category=economy&days=7")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["items"]) == 1
|
||||
|
||||
|
||||
def test_keywords_listing(client):
|
||||
db_module.add_trending_keyword({
|
||||
"keyword": "K", "category": "economy", "score": 0.5, "articles_count": 3,
|
||||
})
|
||||
resp = client.get("/api/insta/keywords?category=economy")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["items"][0]["keyword"] == "K"
|
||||
|
||||
|
||||
def test_create_slate_kicks_background_task(client, monkeypatch):
|
||||
from app import main, card_writer, card_renderer
|
||||
|
||||
def fake_write(keyword, category, articles=None):
|
||||
return db_module.add_card_slate({
|
||||
"keyword": keyword, "category": category, "status": "draft",
|
||||
"cover_copy": {"headline": "H", "body": "B", "accent_color": "#000"},
|
||||
"body_copies": [{"headline": f"h{i}", "body": f"b{i}"} for i in range(8)],
|
||||
"cta_copy": {"headline": "C", "body": "B", "cta": "F"},
|
||||
})
|
||||
|
||||
async def fake_render(slate_id, template="default/card.html.j2"):
|
||||
for i in range(1, 11):
|
||||
db_module.add_card_asset(slate_id, i, f"/tmp/{slate_id}_{i}.png", "h")
|
||||
return [f"/tmp/{slate_id}_{i}.png" for i in range(1, 11)]
|
||||
|
||||
monkeypatch.setattr(card_writer, "write_slate", fake_write)
|
||||
monkeypatch.setattr(card_renderer, "render_slate", fake_render)
|
||||
|
||||
resp = client.post("/api/insta/slates", json={"keyword": "K", "category": "economy"})
|
||||
assert resp.status_code == 200
|
||||
task_id = resp.json()["task_id"]
|
||||
# poll task
|
||||
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"
|
||||
slate_id = st["result_id"]
|
||||
detail = client.get(f"/api/insta/slates/{slate_id}").json()
|
||||
assert detail["status"] == "rendered"
|
||||
assert len(detail["assets"]) == 10
|
||||
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"}
|
||||
89
insta-lab/tests/test_news_collector.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from unittest.mock import patch, MagicMock
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as db_module
|
||||
from app import news_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
|
||||
# Close all SQLite WAL files before removal (needed on Windows)
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
SAMPLE_RESPONSE = {
|
||||
"items": [
|
||||
{
|
||||
"title": "<b>금리</b> 인상 단행",
|
||||
"originallink": "https://news.example.com/1",
|
||||
"link": "https://n.news.naver.com/article/1",
|
||||
"description": "한국은행이 <b>기준금리</b>를 25bp 올렸다.",
|
||||
"pubDate": "Fri, 15 May 2026 08:00:00 +0900",
|
||||
},
|
||||
{
|
||||
"title": "환율 급등",
|
||||
"originallink": "https://news.example.com/2",
|
||||
"link": "https://n.news.naver.com/article/2",
|
||||
"description": "원달러 환율이 1400원을 돌파했다.",
|
||||
"pubDate": "Fri, 15 May 2026 09:00:00 +0900",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_strip_html_and_decode_entities():
|
||||
out = news_collector._clean(' <b>"테스트"</b> & 아이템 ')
|
||||
assert out == '"테스트" & 아이템'
|
||||
|
||||
|
||||
def test_search_news_parses_items(tmp_db):
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.json.return_value = SAMPLE_RESPONSE
|
||||
fake_resp.raise_for_status.return_value = None
|
||||
with patch.object(news_collector.requests, "get", return_value=fake_resp):
|
||||
items = news_collector.search_news("금리", display=10)
|
||||
assert len(items) == 2
|
||||
assert items[0]["title"] == "금리 인상 단행"
|
||||
assert items[0]["summary"].startswith("한국은행")
|
||||
|
||||
|
||||
def test_collect_for_category_inserts(tmp_db):
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.json.return_value = SAMPLE_RESPONSE
|
||||
fake_resp.raise_for_status.return_value = None
|
||||
with patch.object(news_collector.requests, "get", return_value=fake_resp):
|
||||
news_collector.collect_for_category("economy", seed_keywords=["금리"], per_keyword=10)
|
||||
rows = db_module.list_news_articles(category="economy", days=7)
|
||||
assert {r["link"] for r in rows} == {
|
||||
"https://n.news.naver.com/article/1",
|
||||
"https://n.news.naver.com/article/2",
|
||||
}
|
||||
|
||||
|
||||
def test_collect_dedupes_existing(tmp_db):
|
||||
db_module.add_news_article({
|
||||
"category": "economy", "title": "기존",
|
||||
"link": "https://n.news.naver.com/article/1", "summary": ""
|
||||
})
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.json.return_value = SAMPLE_RESPONSE
|
||||
fake_resp.raise_for_status.return_value = None
|
||||
with patch.object(news_collector.requests, "get", return_value=fake_resp):
|
||||
news_collector.collect_for_category("economy", seed_keywords=["금리"])
|
||||
rows = db_module.list_news_articles(category="economy", days=7)
|
||||
# 1 pre-existing + 1 newly added (the other link); UNIQUE link blocks duplicate insert
|
||||
assert len(rows) == 2
|
||||
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"])
|
||||
@@ -9,8 +9,10 @@ DB_PATH = "/app/data/lotto.db"
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, ddl: str) -> None:
|
||||
|
||||
@@ -9,8 +9,10 @@ DB_PATH = "/app/data/music.db"
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# /api/webai/* rate limit — web-ai pull worker (60 req/min, burst 20)
|
||||
limit_req_zone $binary_remote_addr zone=webai:5m rate=60r/m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -132,6 +135,20 @@ server {
|
||||
proxy_pass http://travel-proxy:8000/api/travel/;
|
||||
}
|
||||
|
||||
# webai API — rate limited web-ai pull worker
|
||||
location /api/webai/ {
|
||||
limit_req zone=webai burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-WebAI-Key $http_x_webai_key;
|
||||
proxy_pass http://stock:8000;
|
||||
}
|
||||
|
||||
# stock API
|
||||
location /api/stock/ {
|
||||
proxy_http_version 1.1;
|
||||
@@ -139,41 +156,41 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://stock-lab:8000/api/stock/;
|
||||
proxy_pass http://stock:8000/api/stock/;
|
||||
}
|
||||
|
||||
# trade API (Stock Lab Proxy)
|
||||
# trade API (Stock Proxy)
|
||||
location /api/trade/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://stock-lab:8000/api/trade/;
|
||||
proxy_pass http://stock:8000/api/trade/;
|
||||
}
|
||||
|
||||
# blog-marketing API
|
||||
location /api/blog-marketing/ {
|
||||
# insta API
|
||||
location /api/insta/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $blog_backend blog-lab:8000;
|
||||
set $insta_backend insta-lab:8000;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_pass http://$blog_backend$request_uri;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_pass http://$insta_backend$request_uri;
|
||||
}
|
||||
|
||||
# portfolio API (Stock Lab) — trailing slash 유무 모두 매칭
|
||||
# portfolio API (Stock) — trailing slash 유무 모두 매칭
|
||||
location /api/portfolio {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://stock-lab:8000/api/portfolio;
|
||||
proxy_pass http://stock:8000/api/portfolio;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -133,8 +133,12 @@ async def sign_link(
|
||||
|
||||
# 경로 안전: PACK_HOST_DIR(NAS 호스트 절대경로) 하위인지 확인.
|
||||
# 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()
|
||||
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="허용된 경로 외부")
|
||||
|
||||
try:
|
||||
|
||||
@@ -60,6 +60,29 @@ def test_sign_link_path_outside_base():
|
||||
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():
|
||||
r = client.post(
|
||||
"/api/packs/upload",
|
||||
|
||||
@@ -9,9 +9,10 @@ DB_PATH = "/app/data/personal.db"
|
||||
|
||||
|
||||
def _conn():
|
||||
c = sqlite3.connect(DB_PATH, timeout=10)
|
||||
c = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
c.row_factory = sqlite3.Row
|
||||
c.execute("PRAGMA journal_mode=WAL;")
|
||||
c.execute("PRAGMA busy_timeout=120000;")
|
||||
c.execute("PRAGMA foreign_keys=ON;")
|
||||
return c
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ DB_PATH = os.getenv("REALESTATE_DB_PATH", "/app/data/realestate.db")
|
||||
|
||||
|
||||
def _conn():
|
||||
c = sqlite3.connect(DB_PATH, timeout=10)
|
||||
c = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||
c.row_factory = sqlite3.Row
|
||||
c.execute("PRAGMA journal_mode=WAL;")
|
||||
c.execute("PRAGMA busy_timeout=120000;")
|
||||
c.execute("PRAGMA foreign_keys=ON;")
|
||||
return c
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||
SERVICES="lotto travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab nginx scripts"
|
||||
SERVICES="lotto travel-proxy deployer stock music-lab insta-lab realestate-lab agent-office personal packs-lab nginx scripts"
|
||||
|
||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
#!/bin/bash
|
||||
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) ──
|
||||
exec 200>/tmp/deploy.lock
|
||||
flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
||||
|
||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||
# docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단)
|
||||
BUILD_TARGETS="lotto travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab frontend"
|
||||
# 컨테이너 이름 (고아 정리용)
|
||||
CONTAINER_NAMES="lotto stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy frontend"
|
||||
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab agent-office personal packs-lab frontend"
|
||||
# 컨테이너 이름 (고아 정리용 — blog-lab은 폐기 대상으로 정리 리스트에 유지)
|
||||
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy frontend"
|
||||
# 헬스체크 대상
|
||||
HEALTH_ENDPOINTS="lotto stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office personal packs-lab"
|
||||
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab"
|
||||
# data 디렉토리 (packs-lab은 별도 media/packs 사용)
|
||||
DATA_DIRS="music stock blog realestate agent-office personal"
|
||||
DATA_DIRS="music stock insta realestate agent-office personal"
|
||||
|
||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||
@@ -96,13 +104,25 @@ docker compose up -d --build $BUILD_TARGETS
|
||||
docker exec frontend nginx -s reload 2>/dev/null || true
|
||||
|
||||
# ── 배포 후 헬스체크 ──
|
||||
echo "Waiting for services to start..."
|
||||
sleep 5
|
||||
# Docker compose의 healthcheck 블록이 이미 모든 컨테이너에 정의되어 있으므로
|
||||
# `docker inspect`로 컨테이너 health state를 직접 조회. 이 방식은
|
||||
# (a) deployer 컨테이너 내부에서도 호스트에서도 동일하게 동작
|
||||
# (b) 호스트네임 DNS 해석에 의존하지 않음 (호스트 셸에서는 'lotto' 등 미해석)
|
||||
echo "Waiting for services to become healthy..."
|
||||
|
||||
HEALTH_OK=true
|
||||
for svc in $HEALTH_ENDPOINTS; do
|
||||
if ! curl -sf --max-time 10 --retry 2 --retry-delay 3 "http://$svc:8000/health" > /dev/null 2>&1; then
|
||||
echo "HEALTH_FAIL: http://$svc:8000/health"
|
||||
health="starting"
|
||||
# 최대 60초 (5초×12) 동안 starting → healthy 전이 대기
|
||||
for _ in $(seq 1 12); do
|
||||
health=$(docker inspect --format='{{.State.Health.Status}}' "$svc" 2>/dev/null || echo "missing")
|
||||
if [ "$health" = "healthy" ] || [ "$health" = "unhealthy" ] || [ "$health" = "missing" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
if [ "$health" != "healthy" ]; then
|
||||
echo "HEALTH_FAIL: $svc (state=$health)"
|
||||
HEALTH_OK=false
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -44,8 +44,9 @@ check_url "Music Health" "http://localhost:18600/health"
|
||||
check_url "Music Providers" "http://localhost:18600/api/music/providers"
|
||||
|
||||
echo ""
|
||||
echo "--- 4. Blog Lab ---"
|
||||
check_url "Blog Health" "http://localhost:18700/health"
|
||||
echo "--- 4. Insta Lab ---"
|
||||
check_url "Insta Health" "http://localhost:18700/health"
|
||||
check_url "Insta Status" "http://localhost:18700/api/insta/status"
|
||||
|
||||
echo ""
|
||||
echo "--- 5. Realestate Lab ---"
|
||||
|
||||
@@ -142,6 +142,7 @@ KB증권·삼성증권 등 Open API 미제공 증권사용.
|
||||
"name": "삼성전자",
|
||||
"quantity": 100,
|
||||
"avg_price": 72000,
|
||||
"purchase_price": 72000,
|
||||
"current_price": 74500,
|
||||
"price_session": "NXT_AFTER",
|
||||
"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`로 반환됩니다.
|
||||
> 프론트에서 `null` 체크 후 `"조회 실패"` 등으로 표시해 주세요.
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import List, Dict, Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("stock-lab.ai_summarizer")
|
||||
logger = logging.getLogger("stock.ai_summarizer")
|
||||
|
||||
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "claude").lower().strip()
|
||||
|
||||
37
stock/app/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = logging.getLogger("stock")
|
||||
|
||||
_WEBAI_AUTH_WARNED = False
|
||||
|
||||
|
||||
def verify_webai_key(
|
||||
request: Request,
|
||||
x_webai_key: str | None = Header(default=None, alias="X-WebAI-Key"),
|
||||
) -> None:
|
||||
"""
|
||||
/api/webai/* 보호용 FastAPI dependency.
|
||||
|
||||
- WEBAI_API_KEY env 미설정 → 503 (다른 endpoint 무영향). 1회만 ERROR 로그.
|
||||
- 헤더 누락 또는 키 불일치 → 401 + logger.warning(ip)
|
||||
"""
|
||||
global _WEBAI_AUTH_WARNED
|
||||
configured = os.getenv("WEBAI_API_KEY", "").strip()
|
||||
if not configured:
|
||||
if not _WEBAI_AUTH_WARNED:
|
||||
logger.error("WEBAI_API_KEY not configured — refusing /api/webai/* requests")
|
||||
_WEBAI_AUTH_WARNED = True
|
||||
raise HTTPException(status_code=503, detail="webai auth not configured")
|
||||
|
||||
# env 가 다시 설정되면 flag 해제 → 미래 regression 시 다시 알림
|
||||
if _WEBAI_AUTH_WARNED:
|
||||
_WEBAI_AUTH_WARNED = False
|
||||
|
||||
if not x_webai_key or x_webai_key != configured:
|
||||
remote = request.client.host if request.client else "?"
|
||||
logger.warning("auth_fail path=%s remote=%s", request.url.path, remote)
|
||||
raise HTTPException(status_code=401, detail="invalid or missing X-WebAI-Key")
|
||||
@@ -12,8 +12,10 @@ def _conn() -> sqlite3.Connection:
|
||||
parent = os.path.dirname(db_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn = sqlite3.connect(db_path, timeout=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
@@ -11,7 +11,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from pydantic import BaseModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("stock-lab")
|
||||
logger = logging.getLogger("stock")
|
||||
|
||||
from .db import (
|
||||
init_db, save_articles, get_latest_articles,
|
||||
@@ -24,6 +24,7 @@ from .db import (
|
||||
from .scraper import fetch_market_news, fetch_major_indices
|
||||
from .price_fetcher import get_current_prices, get_current_prices_detail
|
||||
from .ai_summarizer import summarize_news, OllamaError
|
||||
from .auth import verify_webai_key
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -46,13 +47,30 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
# Windows AI Server URL (NAS .env에서 설정)
|
||||
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", "")
|
||||
|
||||
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:
|
||||
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:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
@@ -336,11 +354,11 @@ def get_portfolio():
|
||||
price_session = detail["session"] if detail else None
|
||||
price_as_of = detail["as_of"] if detail else None
|
||||
# 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"]
|
||||
cost_basis = item["avg_price"] * item["quantity"]
|
||||
# 총 매입 금액 표시는 종목별 매입가의 단순 합계 (수량 미곱산)
|
||||
buy_amount = purchase_price
|
||||
# 총 매입 금액 = 단가 × 보유 수량. API_SPEC.md 예시(qty 100·avg 72000 → 7,200,000)와 일치
|
||||
buy_amount = purchase_price * item["quantity"]
|
||||
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_rate = round((profit_amount / cost_basis) * 100, 2) if (profit_amount is not None and cost_basis) else None
|
||||
@@ -384,6 +402,74 @@ def get_portfolio():
|
||||
}
|
||||
|
||||
|
||||
def _augment_portfolio_with_pnl_pct(raw: dict) -> dict:
|
||||
"""Add pnl_pct (ratio) to each holding and total_pnl_pct to summary."""
|
||||
holdings = []
|
||||
for h in raw["holdings"]:
|
||||
pnl_pct = round(h["profit_rate"] / 100, 6) if h.get("profit_rate") is not None else None
|
||||
holdings.append({**h, "pnl_pct": pnl_pct})
|
||||
|
||||
summary = dict(raw["summary"])
|
||||
rate = summary.get("total_profit_rate")
|
||||
summary["total_pnl_pct"] = round(rate / 100, 6) if rate is not None else 0.0
|
||||
|
||||
return {"holdings": holdings, "cash": raw["cash"], "summary": summary}
|
||||
|
||||
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_portfolio():
|
||||
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가)."""
|
||||
return _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||
|
||||
|
||||
def _fetch_news_sentiment_dump(date: str | None) -> dict:
|
||||
"""news_sentiment 일별 dump (krx_master JOIN, score DESC)."""
|
||||
from .db import _conn
|
||||
conn = _conn()
|
||||
try:
|
||||
# 1) date resolve — None 이면 최신 date
|
||||
if date is None:
|
||||
row = conn.execute(
|
||||
"SELECT MAX(date) FROM news_sentiment"
|
||||
).fetchone()
|
||||
date = row[0] if row and row[0] else None
|
||||
|
||||
if date is None:
|
||||
return {"date": None, "count": 0, "items": []}
|
||||
|
||||
# 2) JOIN krx_master.name (없으면 ticker 그대로)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT ns.ticker,
|
||||
COALESCE(km.name, ns.ticker) AS name,
|
||||
ns.score_raw,
|
||||
ns.reason,
|
||||
ns.news_count,
|
||||
ns.source
|
||||
FROM news_sentiment ns
|
||||
LEFT JOIN krx_master km ON km.ticker = ns.ticker
|
||||
WHERE ns.date = ?
|
||||
ORDER BY ns.score_raw DESC
|
||||
""",
|
||||
(date,)
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
items = [
|
||||
{"ticker": r[0], "name": r[1], "score": r[2],
|
||||
"reason": r[3], "news_count": r[4], "source": r[5]}
|
||||
for r in rows
|
||||
]
|
||||
return {"date": date, "count": len(items), "items": items}
|
||||
|
||||
|
||||
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_news_sentiment(date: str | None = None):
|
||||
"""web-ai 전용 news sentiment 일별 dump."""
|
||||
return _fetch_news_sentiment_dump(date)
|
||||
|
||||
|
||||
@app.post("/api/portfolio", status_code=201)
|
||||
def create_portfolio_item(req: PortfolioItemRequest):
|
||||
"""포트폴리오 종목 추가"""
|
||||