61 Commits

Author SHA1 Message Date
d9c39a0206 docs(readme,status): CLAUDE.md 기준으로 동기화 (CODE_REVIEW F7)
README.md / STATUS.md가 blog-lab을 운영 중인 18700 포트 컨테이너로
설명하고 insta-lab/personal/packs-lab을 누락했던 문제 정리. CLAUDE.md를
source of truth로 다음을 갱신:

- 컨테이너 표 (11개로 정합화)
- 디렉토리 구조 (insta-lab/personal/packs-lab 추가)
- 빠른 시작 URL 표
- blog-lab 섹션 → insta-lab 파이프라인 설명
- agent-office 표 (InstaAgent + YouTubeResearcher 반영)
- 스케줄러 잡 목록 (09:00 Insta trends, 09:30 Insta extract, 16:30 screener 등)
- DB 표 (insta.db + personal.db + Supabase pack_files 추가)
- .env 예시 (YOUTUBE_DATA_API_KEY, ADMIN_API_KEY, INSTA_LAB_URL 등)
- STATUS 최근 작업: 2026-05-15~17 인스타 + 보안 fix 이력
2026-05-17 14:23:07 +09:00
0f73b6b07d chore(cleanup): post-migration tidying (CODE_REVIEW F8 + 정리 대상)
- stock/app/test_scraper.py 삭제 — 미존재 함수 fetch_overseas_news를
  import하는 untracked 임시 스크립트. 보존 가치 없음 (F8).
- blog-lab/ 디렉토리 잔재 (__pycache__만 남음) 완전 제거. 서비스는
  feat/insta-agent 머지에서 이미 폐기됨.
- .gitignore에 .superpowers/ (스킬 캐시·세션 메타)와 CODE_REVIEW.md
  (임시 리뷰 노트) 추가 — git status 노이즈 차단.
2026-05-17 14:19:13 +09:00
faffca0967 Merge pull request 'feat/security-hardening' (#5) from feat/security-hardening into main
Reviewed-on: #5
2026-05-17 14:00:03 +09:00
49c5c57be5 docs(env): add ALLOW_UNAUTHENTICATED_ADMIN guidance for F2 2026-05-17 13:58:24 +09:00
6053e69afc fix(stock): admin API auth hardening — ADMIN_API_KEY 빈 값 시 503 거부 (CODE_REVIEW F2)
운영 .env에 ADMIN_API_KEY가 누락되면 verify_admin이 무조건 통과해서
/api/trade/balance, /api/trade/order 인증이 무력화되던 문제 차단.

- ADMIN_API_KEY 설정 + 올바른 키 → 통과 (기존 동작)
- ADMIN_API_KEY 설정 + 잘못된 키 → 401 (기존 동작)
- ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (dev mode)
- ADMIN_API_KEY 미설정 + dev flag 없음 → 503 (신규, 운영 보호)

.env.example에 신규 ALLOW_UNAUTHENTICATED_ADMIN=false 안내 추가.
stock/pytest.ini 신규 (pythonpath=. 설정으로 tests 모듈 import 가능).
test_admin_auth.py 4 케이스 (RED → GREEN 검증, regression 포함).
2026-05-17 13:53:50 +09:00
1e5e1bcdff fix(packs-lab): sign-link path traversal — startswith → relative_to (CODE_REVIEW F1)
str(abs_path).startswith(str(PACK_HOST_DIR))는 trailing slash가 없어
sibling 경로(/foo/packs ↔ /foo/packs_evil)를 통과시켜 DSM API에 잘못된
호스트 경로를 전달할 수 있었음. Path.relative_to 기반으로 컴포넌트 단위
엄격 검증으로 교체. test_sign_link_rejects_sibling_path 회귀 테스트
추가 (RED → GREEN 검증).
2026-05-17 13:50:22 +09:00
64fbbb7958 fix(insta-lab): replace Google Trends with YouTube Data API (Google API 폐기 대응)
Google이 비공식 trends endpoint 두 가지(/trends/.../rss + /trends/api/dailytrends)
모두 404로 폐기 (NAS에서 직접 호출 시 확정). 대안으로 YouTube Data API v3
mostPopular(regionCode=KR, 50개)로 source 교체:

- source 이름: google_trends → youtube_trending
- 키워드: 영상 제목 정제 (대괄호·이모지 제거, 60자 limit)
- API 키: YOUTUBE_DATA_API_KEY (agent-office와 공유, .env 그대로 활용)
- 키 미설정 시 graceful skip
- docker-compose insta-lab에 환경변수 추가
- 테스트 9/9 pass (기존 6 + youtube 3 신규)
2026-05-17 11:54:31 +09:00
cfbb72051f fix(insta-lab): Google Trends — RSS endpoint도 404 폐기, dailytrends JSON API로 교체
Google이 /trends/trendingsearches/daily/rss?geo=KR도 404로 폐기 (직전
fix에서 RSS로 교체했으나 NAS에서 실제 호출 시 404 확인). 대안으로 비공식
/trends/api/dailytrends?hl=ko&tz=-540&geo=KR&ns=15 JSON API로 교체.
응답 앞 `)]}'` XSSI 보호 prefix는 정규식으로 자르고 JSON 파싱.
중복 키워드 제거 + 등장 순서 보존.
2026-05-17 09:30:40 +09:00
bf5897fc85 fix(insta-lab): trend_collector — Google Trends RSS + seed placeholder filter
(1) pytrends 4.x가 Google API 변경으로 trending_searches(pn='south_korea')
가 404 반환 → daily trending searches RSS endpoint를 requests로 직접 호출
하도록 교체. pytrends 의존성 제거.

(2) category_seeds 프롬프트 템플릿에 placeholder ('...', 'TBD' 등) 또는
2자 미만 값이 들어가면 NAVER가 400 Bad Request 반환 → _seeds_for에
_is_valid_seed 가드 추가, 모두 invalid면 DEFAULT_CATEGORY_SEEDS 폴백.

테스트 8/8 PASS (기존 6 + placeholder/fallback 2 신규).
2026-05-17 09:21:38 +09:00
ad6c744f2c fix(deploy): increase docker/buildkit/pip timeouts for NAS slow build
webhook 자동 배포가 pip install (pytrends 추가 후 75s+)에서 buildkit
context deadline exceeded로 실패하던 이슈 대응. scripts/deploy.sh
상단에 COMPOSE_HTTP_TIMEOUT/DOCKER_CLIENT_TIMEOUT/BUILDKIT_STEP_LOG_MAX_SIZE
10분 환경변수 설정 + insta-lab Dockerfile의 pip install에 --timeout 600
--retries 5 추가. NAS Celeron J4025 환경 영구 대응.
2026-05-17 09:03:20 +09:00
aad9bfbe8b Merge pull request 'feat/insta-trends' (#4) from feat/insta-trends into main
Reviewed-on: #4
2026-05-17 08:52:49 +09:00
42bd53ee7b feat(insta): _bg_extract uses preferences + 09:00 trends_collect cron 2026-05-16 17:58:52 +09:00
86694ae4fe feat(agent-office): InstaAgent collect_trends action + preferences-aware on_schedule 2026-05-16 17:57:44 +09:00
41225b3337 feat(insta-lab): main.py — trends + preferences endpoints
- POST /api/insta/trends/collect — background trend collection via trend_collector.collect_all
- GET /api/insta/trends — list external trends with source/category/days filters
- GET /api/insta/preferences — return category weights (defaults seeded on init_db)
- PUT /api/insta/preferences — upsert category weights
- Modified GET /api/insta/keywords to accept source= filter (source present → list_trends, else existing list_trending_keywords, backward compatible)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:54:09 +09:00
6bb5c2fb40 feat(insta-lab): keyword_extractor.extract_with_weights for category proportions 2026-05-16 17:51:16 +09:00
bd1773e29e feat(insta-lab): trend_collector adds Google Trends + LLM category classification 2026-05-16 17:48:26 +09:00
685320f3cf feat(insta-lab): trend_collector with NAVER popular fetcher 2026-05-16 17:47:17 +09:00
b3982c8f72 feat(insta-lab): db migration — trending_keywords.source + account_preferences + CRUD
- Idempotent ALTER TABLE adds source column (default 'manual') + idx_tk_source index
- New account_preferences table seeded with economy/psychology/celebrity at weight=1.0
- add_trending_keyword now accepts optional source param
- New helpers: add_external_trend, list_trends, get_preferences, upsert_preferences
- test_db updated: six→seven tables; test_preferences_crud.py (7 new tests, all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:44:01 +09:00
002c0893f8 chore(insta-lab): add pytrends>=4.9 dependency 2026-05-16 17:41:30 +09:00
d6081ba2d3 docs(insta-trends): implementation plan (10 TDD-grouped tasks)
trend_collector NAVER+Google+LLM 분류, db migration + preferences CRUD,
extract_with_weights, 4 endpoints + keywords source 필터, InstaAgent
collect_trends action + preferences-aware schedule, web-ui 탭 + 3 패널,
스모크 매트릭스.
2026-05-16 17:39:19 +09:00
10cb3ae1df docs(insta-trends): 셀프 리뷰 보강 — LLM 분류 캐시 위치, days 쿼리 의미 명시 2026-05-16 17:31:22 +09:00
e3348da642 docs(insta-trends): 외부 트렌드 + 카테고리 가중치 설계
NAVER 인기 + Google Trends 두 source 수집, account_preferences로 카테고리
가중치 모델, 가중치 기반 키워드 추출 알고리즘, Insta 페이지 Cards/Trends
탭 분리.
2026-05-16 17:30:45 +09:00
088bbaa097 fix(deploy): use docker inspect for healthcheck (호스트/컨테이너 둘 다 동작)
기존 curl http://lotto:8000/health은 deployer 컨테이너 내부에서만
Docker DNS가 'lotto'를 해석. 호스트 셸에서 sudo bash로 직접 실행 시
DNS 해석 실패해 모든 서비스가 HEALTH_FAIL로 오판정. docker inspect로
이미 정의된 compose healthcheck 결과를 직접 조회하도록 변경. starting
상태는 최대 60초 대기 후 최종 판정.
2026-05-16 02:11:38 +09:00
be322557ee fix(insta-lab): pin to bookworm + manual Chromium deps (drop --with-deps)
python:3.12-slim이 trixie(Debian 13)로 옮겨가면서 Playwright 1.48의
--with-deps가 ttf-ubuntu-font-family / ttf-unifont 등 ubuntu20.04
fallback 패키지를 시도하다 apt 실패 → Docker build exit 100.

해결: python:3.12-slim-bookworm 명시(Debian 12, Playwright 공식 지원)
+ Chromium 런타임 라이브러리 직접 apt 설치 + --with-deps 제거.
2026-05-16 01:58:53 +09:00
70438caa1f fix(scripts): blog-lab → insta-lab in deploy/healthcheck service lists
배포 스크립트 hardcoded 서비스 리스트가 blog-lab을 참조해 머지 후
첫 webhook 배포가 rsync(/repo/blog-lab 없음) + docker compose
(서비스 미정의) 양쪽에서 실패. SERVICES/BUILD_TARGETS/HEALTH_ENDPOINTS/
DATA_DIRS를 insta-lab 기준으로 갱신. CONTAINER_NAMES는 blog-lab 고아
정리용으로 유지(다음번 docker rm -f가 안전 실행).
2026-05-16 01:51:45 +09:00
e16029ebdb Merge pull request 'feat/insta-agent' (#3) from feat/insta-agent into main
Reviewed-on: #3
2026-05-16 01:43:21 +09:00
cefc3119c0 docs(claude-md): replace blog-lab references with insta-lab 2026-05-16 00:53:58 +09:00
5485d4858a chore: remove blog-lab service and BlogAgent (replaced by insta-lab) 2026-05-16 00:52:05 +09:00
fbd963db86 feat(agent-office): telegram render_<id> callback dispatches to InstaAgent 2026-05-16 00:49:30 +09:00
9095423026 feat(agent-office): register InstaAgent + 09:30 cron job 2026-05-16 00:47:28 +09:00
6eb24090ed feat(agent-office): InstaAgent — daily extract + keyword push + media group render 2026-05-16 00:47:24 +09:00
8cb5a01431 feat(agent-office): replace blog_* proxy with insta_* helpers 2026-05-16 00:47:16 +09:00
8a4a8790ca chore(agent-office): swap BLOG_LAB_URL for INSTA_LAB_URL 2026-05-16 00:47:12 +09:00
2200748122 chore(nginx): replace /api/blog-marketing with /api/insta 2026-05-16 00:40:41 +09:00
7bc0a7cd77 chore(compose): replace blog-lab service with insta-lab 2026-05-16 00:40:26 +09:00
b84efd730b feat(insta-lab): main.py FastAPI endpoints + BackgroundTasks
13 REST endpoints covering health, status, news, keywords, slates,
tasks, and prompt templates. All background functions are async def
so FastAPI awaits them without asyncio.run conflicts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:38:34 +09:00
11bd223612 feat(insta-lab): card_renderer with Jinja + Playwright (1080x1350) 2026-05-16 00:35:55 +09:00
c3a5d7210f feat(insta-lab): card_writer with Claude 10-page JSON generator 2026-05-16 00:31:34 +09:00
07c4459085 feat(insta-lab): keyword_extractor with frequency + Claude refinement 2026-05-16 00:30:38 +09:00
c057304981 feat(insta-lab): news_collector with NAVER news.json + dedupe 2026-05-16 00:27:13 +09:00
d1245d040c feat(insta-lab): db.py with 6 tables + CRUD 2026-05-16 00:26:28 +09:00
34ca407ca2 feat(insta-lab): anchor templates/default/ directory with .gitkeep 2026-05-16 00:22:42 +09:00
b1ef778fc5 feat(insta-lab): project scaffold (Dockerfile, requirements, config)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:20:49 +09:00
30706e2eb6 docs(insta-agent): add implementation plan (18 TDD tasks)
scaffold → db → news_collector → keyword_extractor → card_writer →
card_renderer → main.py FastAPI → docker-compose/nginx 교체 →
agent-office service_proxy/InstaAgent/registry/scheduler/webhook
콜백 → blog-lab 폐기 → CLAUDE.md → 스모크 테스트.
2026-05-15 08:58:15 +09:00
6062445c12 fix(stock-webai): final review notes — env default + 1-time auth error log
(1) docker-compose: ${WEBAI_API_KEY} → ${WEBAI_API_KEY:-} matches
project convention, avoids "variable not set" warning when NAS .env
lacks the key during initial deploy.

(2) auth.py: ERROR log when WEBAI_API_KEY env unset fires only on
first miss, then silent (module-level _WEBAI_AUTH_WARNED flag).
Flag resets when env becomes configured, so future regressions log
again. Eliminates log spam under web-ai polling (~3/min).

All 102 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:56:03 +09:00
13da2226c3 feat(nginx-webai): /api/webai/ location with rate limit + X-WebAI-Key forward
limit_req_zone webai:5m rate=60r/m, burst=20 nodelay, return 429 on
limit hit. Proxies to stock:8000 with X-Real-IP, X-Forwarded-For,
and X-WebAI-Key headers preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:48:14 +09:00
1e377e1559 chore(stock-webai): pass WEBAI_API_KEY env to stock container
Required by /api/webai/* endpoints. Operator must set WEBAI_API_KEY
in NAS /volume1/docker/webpage/.env before deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:46:52 +09:00
eb75d692f5 test(stock-webai): edge cases — 401 no leak, 503 env missing, unknown date
Verifies auth failure responses contain no portfolio/sentiment data,
503 when WEBAI_API_KEY env unset (existing endpoints unaffected),
news-sentiment unknown date returns empty result.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:44:17 +09:00
6c25866487 docs(insta-agent): 셀프 리뷰 수정 — 6테이블 표기 일치, auto_select 설정 위치 명확화 2026-05-15 08:42:38 +09:00
6ac7469f26 docs(insta-agent): blog-lab 폐기 및 insta-lab 설계 (1080x1350 카드 피드)
뉴스 수집 → 키워드 추출 → 10페이지 카드 카피·PNG 생성 → 텔레그램 푸시 →
사용자 수동 인스타 업로드 파이프라인. blog-lab 디렉토리·DB 폐기, 포트
18700 재활용, agents/blog.py → agents/insta.py, Playwright 기반 카드 렌더.
2026-05-15 08:42:03 +09:00
d1b2b6a4ba feat(stock-webai): /api/webai/news-sentiment daily dump
JOINs news_sentiment with krx_master for name fallback. Sorted by
score DESC. Date param defaults to latest. Empty table returns
{date: null, count: 0, items: []}. 4 integration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:40:49 +09:00
2abfa5cb23 feat(stock-webai): /api/webai/portfolio + pnl_pct augment
Reuses get_portfolio() and adds pnl_pct (ratio, profit_rate/100) to
each holding plus total_pnl_pct to summary. 4 integration tests pass.
verify_webai_key dependency enforced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:36:27 +09:00
227e294bd3 feat(stock-webai): add X-WebAI-Key auth dependency + tests
verify_webai_key FastAPI dependency: 401 on missing/wrong key,
503 when WEBAI_API_KEY env unset. 4 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:33:07 +09:00
ace0339d33 refactor: rename stock-lab → stock (graduation)
- git mv stock-lab/ → stock/
- docker-compose.yml: 서비스 키 + container_name + build.context +
  frontend.depends_on + agent-office STOCK_LAB_URL → STOCK_URL
- agent-office/app: config.py, service_proxy.py, agents/stock.py, tests/
  STOCK_LAB_URL → STOCK_URL
- nginx/default.conf: proxy_pass http://stock-labhttp://stock (3 lines)
- CLAUDE.md / README.md / STATUS.md / scripts/ 문구 갱신
- stock/ 내부 자기 참조 갱신

lab 네이밍 정책 (feedback_lab_naming.md) graduation.
API URL / Python import / DB 파일명 변경 없음.
2026-05-15 01:45:44 +09:00
8812bd870a docs(ai_news): mark scraper.py deprecated (Phase 1 transition) 2026-05-14 02:13:30 +09:00
b3fac4f442 feat(ai_news): router forwards mapping stats to telegram 2026-05-14 02:13:06 +09:00
19aed304cb feat(ai_news): telegram includes article mapping stats line 2026-05-14 02:12:17 +09:00
bbe5221e57 feat(ai_news): pipeline uses articles_source (replaces Naver scraper) 2026-05-14 02:09:41 +09:00
ec0ccf649e feat(ai_news): include summary + pub_date in LLM prompt 2026-05-14 02:07:01 +09:00
84d90f6e1c feat(ai_news): articles_source module (substring ticker matching) 2026-05-14 02:04:32 +09:00
ddfe0ca3eb feat(ai_news): add news_sentiment.source column with migration 2026-05-14 02:00:38 +09:00
141 changed files with 9053 additions and 3203 deletions

View File

@@ -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

8
.gitignore vendored
View File

@@ -66,3 +66,11 @@ temp/
# Git worktrees
.worktrees/
################################
# Local working files
################################
# Superpowers 스킬 캐시·세션 메타
.superpowers/
# 임시 코드 리뷰 노트 (작업 끝나면 폐기 또는 docs/로 이동)
CODE_REVIEW.md

115
CLAUDE.md
View File

@@ -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,55 @@ 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`)
- `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위 키워드 자동 슬레이트 생성·발송
**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 +512,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 +687,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
View File

@@ -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`

View File

@@ -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

View File

@@ -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()

View File

@@ -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}")

View 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

View File

@@ -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", "뉴스 요약 전송 중...")
@@ -237,7 +237,7 @@ class StockAgent(BaseAgent):
"""AI 뉴스 sentiment 분석 자동 잡 (평일 08:00 KST).
흐름:
1) stock-lab /snapshot/refresh-news-sentiment 호출
1) stock /snapshot/refresh-news-sentiment 호출
2) status='skipped_weekend'/'skipped_holiday' → 종료 (텔레그램 미발신)
3) updated=0 → 운영자 알림 (HTML)
4) failures > 30% → 경고 알림 후 메인 메시지 발송
@@ -304,10 +304,10 @@ class StockAgent(BaseAgent):
except Exception:
pass
# 정상 — Top 5 메시지 (stock-lab이 빌드해서 응답에 telegram_text 동봉)
# 정상 — Top 5 메시지 (stock이 빌드해서 응답에 telegram_text 동봉)
text = result.get("telegram_text") or ""
if not text:
add_log(self.agent_id, "telegram_text 누락 — stock-lab 응답 결함", "error", task_id)
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

View File

@@ -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

View File

@@ -24,11 +24,17 @@ async def _run_stock_ai_news():
if agent:
await agent.on_ai_news_schedule()
async def _run_blog_schedule():
agent = AGENT_REGISTRY.get("blog")
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:
@@ -67,7 +73,8 @@ def init_scheduler():
minute=0,
id="stock_ai_news_sentiment",
)
scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline")
scheduler.add_job(_run_insta_schedule, "cron", hour=9, minute=30, id="insta_pipeline")
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends_collect")
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0, id="lotto_curate")
scheduler.add_job(_run_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")

View File

@@ -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,32 +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-lab의 AI 뉴스 sentiment 분석 트리거 (08:00 cron).
"""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_LAB_URL}/api/stock/screener/snapshot/refresh-news-sentiment"
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 없음
@@ -67,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()
@@ -75,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()
@@ -101,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]:

View File

@@ -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

View 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()

View 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()

View File

@@ -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), \

View File

@@ -1,4 +0,0 @@
__pycache__
*.pyc
.env
data/

View File

@@ -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"]

View File

@@ -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")

View File

@@ -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", ""))

View File

@@ -1,790 +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, 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() -> 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

View File

@@ -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()

View File

@@ -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],
}

View File

@@ -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

View File

@@ -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]}",
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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으로 원본 반환

View File

@@ -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"] == ""

View File

@@ -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"

View File

@@ -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,26 @@ 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
- 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 +142,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 +163,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')"]
@@ -242,9 +248,9 @@ services:
restart: unless-stopped
depends_on:
- lotto
- stock-lab
- stock
- music-lab
- blog-lab
- insta-lab
- realestate-lab
- agent-office
- personal

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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 전체 통과

View File

@@ -0,0 +1,247 @@
# insta-lab Trends 탭 설계 — 외부 트렌드 수집 + 카테고리 가중치
작성일: 2026-05-16
상태: 사용자 승인 대기 → writing-plans 진입 예정
연관 문서: `2026-05-15-insta-agent-design.md` (insta-lab 기본 설계)
---
## 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 전체 통과

26
insta-lab/Dockerfile Normal file
View 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"]

View File

@@ -0,0 +1,100 @@
"""Jinja → HTML → Playwright headless screenshot."""
import asyncio
import hashlib
import json
import logging
import os
import tempfile
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()
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

View 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

26
insta-lab/app/config.py Normal file
View File

@@ -0,0 +1,26 @@
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")
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
View 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)))

View 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

305
insta-lab/app/main.py Normal file
View File

@@ -0,0 +1,305 @@
"""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,
)
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)
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)
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()}

View 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

View 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>

View 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&regionCode=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&regionCode=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}

View File

@@ -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

View File

@@ -0,0 +1,48 @@
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))

View 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")

View 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"

View 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())

View 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} == {"기준금리", "환율"}

View 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

View 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"}

View 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> &amp; 아이템 ')
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

View 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"}

View 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"])

View File

@@ -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;
}

View File

@@ -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:

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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 ---"

View File

@@ -1,110 +0,0 @@
import datetime as dt
import sqlite3
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from app.screener.ai_news import pipeline
from app.screener.schema import ensure_screener_schema
@pytest.fixture
def conn():
c = sqlite3.connect(":memory:")
c.row_factory = sqlite3.Row
ensure_screener_schema(c)
# 시총 상위 3종목 시드
c.execute("INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
"VALUES (?, ?, 'KOSPI', ?, datetime('now'))", ("005930", "삼성전자", 9_000_000))
c.execute("INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
"VALUES (?, ?, 'KOSPI', ?, datetime('now'))", ("000660", "SK하이닉스", 8_000_000))
c.execute("INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
"VALUES (?, ?, 'KOSPI', ?, datetime('now'))", ("373220", "LG에너지솔루션", 7_000_000))
c.commit()
yield c
c.close()
@pytest.mark.asyncio
async def test_refresh_daily_happy_path(conn):
"""3종목 mini integration — 각 종목별로 scraper/analyzer mock."""
asof = dt.date(2026, 5, 13)
fake_news = [{"title": "헤드라인"}]
async def fake_fetch(client, ticker, n):
return fake_news
scores_by_ticker = {
"005930": 7.5, "000660": 4.0, "373220": -6.0,
}
async def fake_score(llm, ticker, news, *, name=None, model="m"):
return {
"ticker": ticker, "score_raw": scores_by_ticker[ticker],
"reason": f"r{ticker}", "news_count": 1,
"tokens_input": 100, "tokens_output": 20, "model": model,
}
with patch.object(pipeline, "_scraper") as ms, \
patch.object(pipeline, "_analyzer") as ma, \
patch.object(pipeline, "_make_llm") as ml, \
patch.object(pipeline, "_make_http") as mh:
ms.fetch_news = fake_fetch
ma.score_sentiment = fake_score
ml.return_value.__aenter__.return_value = AsyncMock()
ml.return_value.__aexit__.return_value = None
mh.return_value.__aenter__.return_value = AsyncMock()
mh.return_value.__aexit__.return_value = None
result = await pipeline.refresh_daily(conn, asof, concurrency=3, rate_limit_sec=0)
assert result["asof"] == "2026-05-13"
assert result["updated"] == 3
assert result["failures"] == []
assert len(result["top_pos"]) == 3
assert result["top_pos"][0]["ticker"] == "005930" # 가장 큰 점수
assert result["top_neg"][0]["ticker"] == "373220" # 가장 작은 점수
assert result["tokens_input"] == 300
assert result["tokens_output"] == 60
# DB upsert 확인
rows = conn.execute("SELECT ticker, score_raw FROM news_sentiment WHERE date=?",
("2026-05-13",)).fetchall()
assert len(rows) == 3
by_ticker = {r["ticker"]: r["score_raw"] for r in rows}
assert by_ticker["005930"] == 7.5
assert by_ticker["373220"] == -6.0
@pytest.mark.asyncio
async def test_refresh_daily_failures_isolated(conn):
"""한 종목이 예외 던져도 나머지 종목은 정상 처리."""
asof = dt.date(2026, 5, 13)
async def fake_fetch(client, ticker, n):
return [{"title": "h"}]
async def fake_score(llm, ticker, news, *, name=None, model="m"):
if ticker == "000660":
raise RuntimeError("llm exploded")
return {
"ticker": ticker, "score_raw": 5.0, "reason": "r", "news_count": 1,
"tokens_input": 100, "tokens_output": 20, "model": model,
}
with patch.object(pipeline, "_scraper") as ms, \
patch.object(pipeline, "_analyzer") as ma, \
patch.object(pipeline, "_make_llm") as ml, \
patch.object(pipeline, "_make_http") as mh:
ms.fetch_news = fake_fetch
ma.score_sentiment = fake_score
ml.return_value.__aenter__.return_value = AsyncMock()
ml.return_value.__aexit__.return_value = None
mh.return_value.__aenter__.return_value = AsyncMock()
mh.return_value.__aexit__.return_value = None
result = await pipeline.refresh_daily(conn, asof, concurrency=3, rate_limit_sec=0)
assert result["updated"] == 2
assert len(result["failures"]) == 1
def test_top_market_cap_tickers(conn):
out = pipeline._top_market_cap_tickers(conn, n=2)
assert out == ["005930", "000660"]

View File

@@ -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
View 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")

View File

@@ -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")
@@ -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):
"""포트폴리오 종목 추가"""

View File

@@ -4,7 +4,7 @@ from bs4 import BeautifulSoup
from typing import List, Dict, Any
import time
logger = logging.getLogger("stock-lab.scraper")
logger = logging.getLogger("stock.scraper")
# 네이버 파이낸스 주요 뉴스
NAVER_FINANCE_NEWS_URL = "https://finance.naver.com/news/mainnews.naver"

View File

View File

@@ -27,6 +27,25 @@ def _clamp(x: float, lo: float = -10.0, hi: float = 10.0) -> float:
return max(lo, min(hi, x))
def _format_news_block(news: List[Dict[str, Any]]) -> str:
"""news dict 리스트 → prompt 에 들어가는 텍스트 블록.
summary 있으면 title 다음 줄에 indent 해서 포함 (최대 200).
pub_date 있으면 title 앞에 표시.
"""
lines: List[str] = []
for n in news:
date = (n.get("pub_date") or "").strip()
title = (n.get("title") or "").strip()
summary = (n.get("summary") or "").strip()
prefix = f"[{date}] " if date else ""
if summary:
lines.append(f"- {prefix}{title}\n {summary[:200]}")
else:
lines.append(f"- {prefix}{title}")
return "\n".join(lines)
async def score_sentiment(
llm,
ticker: str,
@@ -36,7 +55,7 @@ async def score_sentiment(
model: str = DEFAULT_MODEL,
) -> Dict[str, Any]:
"""Returns {ticker, score_raw, reason, news_count, tokens_input, tokens_output, model}."""
news_block = "\n".join(f"- {n['title']}" for n in news)
news_block = _format_news_block(news)
prompt = PROMPT_TEMPLATE.format(
name=name or ticker, ticker=ticker,
n=len(news), news_block=news_block,

View File

@@ -0,0 +1,70 @@
"""기존 articles 테이블에서 종목별 뉴스 매핑."""
from __future__ import annotations
import datetime as dt
import logging
import sqlite3
from typing import Any, Dict, List, Tuple
log = logging.getLogger(__name__)
def gather_articles_for_tickers(
conn: sqlite3.Connection,
tickers: List[str],
asof: dt.date,
*,
window_days: int = 1,
max_per_ticker: int = 5,
) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, int]]:
"""articles 에서 ticker.name substring 매칭으로 종목별 뉴스 dict 반환.
Returns:
(
{ticker: [{"title": str, "summary": str, "press": str, "pub_date": str}, ...]},
{"total_articles": int, "matched_pairs": int, "hit_tickers": int},
)
"""
out: Dict[str, List[Dict[str, Any]]] = {t: [] for t in tickers}
stats = {"total_articles": 0, "matched_pairs": 0, "hit_tickers": 0}
if not tickers:
return out, stats
cutoff = (asof - dt.timedelta(days=window_days)).isoformat()
placeholders = ",".join("?" * len(tickers))
name_rows = conn.execute(
f"SELECT ticker, name FROM krx_master WHERE ticker IN ({placeholders})",
tickers,
).fetchall()
# 2글자 미만 회사명은 false positive 위험으로 제외
name_map = {r[0]: r[1] for r in name_rows if r[1] and len(r[1]) >= 2}
articles = conn.execute(
"SELECT title, summary, press, pub_date, crawled_at "
"FROM articles WHERE crawled_at >= ? ORDER BY crawled_at DESC",
(cutoff,),
).fetchall()
stats["total_articles"] = len(articles)
for a in articles:
title = (a[0] or "").strip()
summary = (a[1] or "").strip()
haystack = title + " " + summary
for ticker, name in name_map.items():
if name not in haystack:
continue
if len(out[ticker]) >= max_per_ticker:
continue
out[ticker].append({
"title": title,
"summary": summary,
"press": a[2] or "",
"pub_date": a[3] or "",
})
stats["matched_pairs"] += 1
stats["hit_tickers"] = sum(1 for arts in out.values() if arts)
return out, stats

View File

@@ -8,19 +8,17 @@ import logging
import os
import sqlite3
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import httpx
from . import scraper as _scraper
from . import scraper as _scraper # legacy, kept for backward import
from . import analyzer as _analyzer
from . import articles_source # 신규
log = logging.getLogger(__name__)
DEFAULT_TOP_N = 100
DEFAULT_CONCURRENCY = 10
DEFAULT_NEWS_PER_TICKER = 5
DEFAULT_RATE_LIMIT_SEC = 0.2
def _top_market_cap_tickers(conn: sqlite3.Connection, n: int) -> List[str]:
@@ -33,10 +31,6 @@ def _top_market_cap_tickers(conn: sqlite3.Connection, n: int) -> List[str]:
return [r[0] for r in rows]
def _make_http():
return httpx.AsyncClient(timeout=10.0, headers=_scraper.NAVER_HEADERS)
def _make_llm():
"""Anthropic AsyncClient — env에 ANTHROPIC_API_KEY 필수."""
from anthropic import AsyncAnthropic
@@ -44,47 +38,40 @@ def _make_llm():
async def _process_one(
ticker: str, name: str, sem: asyncio.Semaphore,
http_client, llm, news_per_ticker: int, rate_limit_sec: float, model: str,
ticker: str, name: str, articles: List[Dict[str, Any]],
sem: asyncio.Semaphore, llm, model: str,
) -> Dict[str, Any]:
async with sem:
if rate_limit_sec > 0:
await asyncio.sleep(rate_limit_sec)
news = await _scraper.fetch_news(http_client, ticker, n=news_per_ticker)
if not news:
return {
"ticker": ticker, "score_raw": 0.0, "reason": "no news",
"news_count": 0, "tokens_input": 0, "tokens_output": 0,
"model": model,
}
return await _analyzer.score_sentiment(
llm, ticker, news, name=name, model=model,
llm, ticker, articles, name=name, model=model,
)
def _upsert_news_sentiment(
conn: sqlite3.Connection, asof: dt.date, rows: List[Dict[str, Any]]
conn: sqlite3.Connection, asof: dt.date,
rows: List[Dict[str, Any]], *, source: str = "articles",
) -> None:
iso = asof.isoformat()
data = [
(
r["ticker"], iso, r["score_raw"], r["reason"], r["news_count"],
r["tokens_input"], r["tokens_output"], r["model"],
r["tokens_input"], r["tokens_output"], r["model"], source,
)
for r in rows
]
conn.executemany(
"""INSERT INTO news_sentiment
(ticker, date, score_raw, reason, news_count,
tokens_input, tokens_output, model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
tokens_input, tokens_output, model, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ticker, date) DO UPDATE SET
score_raw=excluded.score_raw,
reason=excluded.reason,
news_count=excluded.news_count,
tokens_input=excluded.tokens_input,
tokens_output=excluded.tokens_output,
model=excluded.model
model=excluded.model,
source=excluded.source
""",
data,
)
@@ -97,11 +84,10 @@ async def refresh_daily(
*,
top_n: int = DEFAULT_TOP_N,
concurrency: int = DEFAULT_CONCURRENCY,
news_per_ticker: int = DEFAULT_NEWS_PER_TICKER,
rate_limit_sec: float = DEFAULT_RATE_LIMIT_SEC,
max_news_per_ticker: int = DEFAULT_NEWS_PER_TICKER,
window_days: int = 1,
model: str = _analyzer.DEFAULT_MODEL,
) -> Dict[str, Any]:
"""Returns summary dict with top_pos/top_neg/token totals/failures."""
started = time.time()
tickers = _top_market_cap_tickers(conn, n=top_n)
name_map = {
@@ -111,16 +97,20 @@ async def refresh_daily(
).fetchall()
} if tickers else {}
sem = asyncio.Semaphore(concurrency)
articles_by_ticker, mapping_stats = articles_source.gather_articles_for_tickers(
conn, tickers, asof,
window_days=window_days,
max_per_ticker=max_news_per_ticker,
)
async with _make_http() as http_client, _make_llm() as llm:
tasks = [
_process_one(
t, name_map.get(t, t), sem, http_client, llm,
news_per_ticker, rate_limit_sec, model,
)
for t in tickers
]
sem = asyncio.Semaphore(concurrency)
async with _make_llm() as llm:
tasks = []
for t in tickers:
arts = articles_by_ticker.get(t, [])
if not arts:
continue # 매핑 0 — score 미생성
tasks.append(_process_one(t, name_map.get(t, t), arts, sem, llm, model))
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
successes: List[Dict[str, Any]] = []
@@ -132,7 +122,7 @@ async def refresh_daily(
successes.append(r)
if successes:
_upsert_news_sentiment(conn, asof, successes)
_upsert_news_sentiment(conn, asof, successes, source="articles")
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
@@ -147,4 +137,5 @@ async def refresh_daily(
"top_pos": top_pos,
"top_neg": top_neg,
"model": model,
"mapping": mapping_stats,
}

View File

@@ -1,4 +1,11 @@
"""네이버 finance 종목 뉴스 스크래핑."""
"""[DEPRECATED] 네이버 finance 종목 뉴스 스크래핑.
모듈은 ai_news Phase 1 (2026-05-14) 에서 이상 파이프라인에서 사용되지 않음.
데이터 소스는 stock articles 테이블 (ai_news/articles_source.py) 전환됨.
삭제 시점: Phase 2 (DART 도입) 결정 . IC 검증 4 누적 노드 활성화
여부에 따라 모듈을 (a) 완전 삭제 또는 (b) ensemble fallback 으로 재활용.
"""
from __future__ import annotations

View File

@@ -37,6 +37,7 @@ def build_message(
top_neg: List[Dict[str, Any]],
tokens_input: int,
tokens_output: int,
mapping: Dict[str, int] | None = None,
) -> str:
lines: List[str] = [
f"🌅 *AI 뉴스 분석* \\({_escape(asof)} 08:00\\)",
@@ -57,9 +58,16 @@ def build_message(
lines.append(_escape("- (없음)"))
cost = _cost_won(tokens_input, tokens_output)
mapping_part = ""
if mapping:
mapping_part = (
f"매핑 {mapping['hit_tickers']}/100 ticker "
f"\\({mapping['matched_pairs']}쌍 / articles {mapping['total_articles']}\\) · "
)
lines += [
"",
f"_분석: 시총 상위 100종목 · 토큰 {tokens_input:,} in / {tokens_output:,} out · "
f"_분석: 시총 상위 100종목 · {mapping_part}"
f"토큰 {tokens_input:,} in / {tokens_output:,} out · "
f"약 ₩{cost:,}_",
]
return "\n".join(lines)

View File

Some files were not shown because too many files have changed in this diff Show More