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