# 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장 + 추천 캡션·해시태그 ─ 사용자 다운로드 후 인스타 수동 업로드 ``` ### 자동 모드 (옵션) - `auto_select=true` 설정 시 키워드 추출 직후 카테고리당 score 1위 키워드를 자동 선택해 4~6 단계까지 즉시 진행 - 사용자가 텔레그램에서 결과만 확인 --- ## 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 # 5테이블 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 / list_categories - on_callback: 텔레그램 inline button "render_" → 카피·렌더·푸시 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 전체 통과