finish poc
parent
247a9f3322
commit
f4821bf157
|
|
@ -0,0 +1,335 @@
|
||||||
|
# Instagram Graph API POC 개발 계획
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
- **목적**: Instagram Graph API (비즈니스 계정) 활용 POC 구현
|
||||||
|
- **위치**: `poc/instagram/`
|
||||||
|
- **기술 스택**: Python 3.13, 비동기(async/await), httpx, Pydantic v2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실행 파이프라인
|
||||||
|
|
||||||
|
| 단계 | 에이전트 | 목적 | 산출물 |
|
||||||
|
|------|----------|------|--------|
|
||||||
|
| 1 | `/design` | 초기 설계 | DESIGN.md |
|
||||||
|
| 2 | `/develop` | 초기 개발 | 소스 코드 |
|
||||||
|
| 3 | `/review` | 초기 리뷰 | REVIEW_V1.md |
|
||||||
|
| 4 | `/design` | 리팩토링 설계 | DESIGN.md 갱신 |
|
||||||
|
| 5 | `/develop` | 리팩토링 개발 | 개선된 코드 |
|
||||||
|
| 6 | `/review` | 최종 리뷰 | REVIEW_FINAL.md |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1단계: `/design` (초기 설계)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 프로젝트 개요
|
||||||
|
Instagram Graph API (비즈니스 계정) POC 초기 설계
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- 위치: poc/instagram/
|
||||||
|
- 기술: Python 3.13, 비동기(async/await), httpx, Pydantic v2
|
||||||
|
- 참고: https://developers.facebook.com/docs/instagram-api
|
||||||
|
|
||||||
|
## 설계 요구사항
|
||||||
|
|
||||||
|
### 1. 아키텍처 설계
|
||||||
|
- 모듈 구조 및 의존성 관계
|
||||||
|
- 클래스 다이어그램 (Client, Models, Exceptions)
|
||||||
|
|
||||||
|
### 2. API 엔드포인트 분석
|
||||||
|
공식 문서 기반으로 다음 기능의 엔드포인트, 파라미터, 응답 구조 정리:
|
||||||
|
- 인증: Token 교환, 검증
|
||||||
|
- 계정: 비즈니스 계정 ID 조회, 프로필 조회
|
||||||
|
- 미디어: 목록/상세 조회, 이미지/비디오 게시 (Container → Publish)
|
||||||
|
- 인사이트: 계정/미디어 인사이트
|
||||||
|
- 댓글: 조회, 답글 작성
|
||||||
|
|
||||||
|
### 3. 데이터 모델 설계
|
||||||
|
- Pydantic 스키마 정의 (Account, Media, Insight, Comment 등)
|
||||||
|
- API 응답 매핑 구조
|
||||||
|
|
||||||
|
### 4. 예외 처리 전략
|
||||||
|
- Rate Limit (429)
|
||||||
|
- 인증 만료/무효
|
||||||
|
- 권한 부족
|
||||||
|
- API 에러 코드별 처리
|
||||||
|
|
||||||
|
### 5. 파일 구조
|
||||||
|
poc/instagram/
|
||||||
|
├── __init__.py
|
||||||
|
├── config.py
|
||||||
|
├── client.py
|
||||||
|
├── models.py
|
||||||
|
├── exceptions.py
|
||||||
|
├── examples/
|
||||||
|
└── README.md
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
- 설계 문서 (poc/instagram/DESIGN.md)
|
||||||
|
- 각 모듈별 인터페이스 명세
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2단계: `/develop` (초기 개발)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 작업 개요
|
||||||
|
poc/instagram/ 폴더에 Instagram Graph API POC 코드 초기 구현
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
- 설계 문서: poc/instagram/DESIGN.md
|
||||||
|
- 공식 문서: https://developers.facebook.com/docs/instagram-api
|
||||||
|
|
||||||
|
## 구현 요구사항
|
||||||
|
|
||||||
|
### config.py
|
||||||
|
- pydantic-settings 기반 Settings 클래스
|
||||||
|
- 환경변수: INSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET, INSTAGRAM_ACCESS_TOKEN
|
||||||
|
- API 버전, Base URL 설정
|
||||||
|
|
||||||
|
### exceptions.py
|
||||||
|
- InstagramAPIError (기본 예외)
|
||||||
|
- RateLimitError (429)
|
||||||
|
- AuthenticationError (인증 실패)
|
||||||
|
- PermissionError (권한 부족)
|
||||||
|
- MediaPublishError (게시 실패)
|
||||||
|
|
||||||
|
### models.py (Pydantic v2)
|
||||||
|
- TokenInfo, Account, Media, MediaInsight, Comment
|
||||||
|
|
||||||
|
### client.py
|
||||||
|
- InstagramGraphClient 클래스 (httpx.AsyncClient)
|
||||||
|
- 인증, 계정, 미디어, 인사이트, 댓글 관련 메서드
|
||||||
|
- 요청/응답 로깅, 재시도 로직
|
||||||
|
|
||||||
|
### examples/
|
||||||
|
- 각 기능별 실행 가능한 예제
|
||||||
|
|
||||||
|
### README.md
|
||||||
|
- 설치, 설정, 실행 가이드
|
||||||
|
|
||||||
|
## 코드 품질
|
||||||
|
- 타입 힌트, docstring, 로깅 필수
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3단계: `/review` (초기 리뷰)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 리뷰 대상
|
||||||
|
poc/instagram/ 폴더의 Instagram Graph API POC 초기 구현 코드
|
||||||
|
|
||||||
|
## 리뷰 항목
|
||||||
|
|
||||||
|
### 1. 코드 품질
|
||||||
|
- 타입 힌트 완전성
|
||||||
|
- 네이밍 컨벤션 (PEP8)
|
||||||
|
- 코드 중복 여부
|
||||||
|
|
||||||
|
### 2. 아키텍처
|
||||||
|
- 모듈 간 의존성 적절성
|
||||||
|
- 단일 책임 원칙 준수
|
||||||
|
- 확장 가능성
|
||||||
|
|
||||||
|
### 3. 에러 처리
|
||||||
|
- 예외 처리 누락 여부
|
||||||
|
- Rate Limit 처리 적절성
|
||||||
|
|
||||||
|
### 4. 보안
|
||||||
|
- Credentials 노출 위험
|
||||||
|
- 민감 정보 로깅 여부
|
||||||
|
|
||||||
|
### 5. 비동기 처리
|
||||||
|
- async/await 올바른 사용
|
||||||
|
- 리소스 정리
|
||||||
|
|
||||||
|
### 6. 문서화
|
||||||
|
- README 완성도
|
||||||
|
- 예제 코드 실행 가능성
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
- 리뷰 결과 (poc/instagram/REVIEW_V1.md)
|
||||||
|
- 심각도별 분류 (Critical, Major, Minor)
|
||||||
|
- 개선 사항 목록
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4단계: `/design` (리팩토링 설계)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 프로젝트 개요
|
||||||
|
Instagram Graph API POC 리팩토링 설계
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- 초기 리뷰 결과: poc/instagram/REVIEW_V1.md
|
||||||
|
- 기존 설계: poc/instagram/DESIGN.md
|
||||||
|
- 기존 코드: poc/instagram/
|
||||||
|
|
||||||
|
## 리팩토링 설계 요구사항
|
||||||
|
|
||||||
|
### 1. 리뷰 피드백 반영
|
||||||
|
- REVIEW_V1.md의 Critical/Major 이슈 해결 방안
|
||||||
|
- 아키텍처 개선점 반영
|
||||||
|
|
||||||
|
### 2. 개선된 아키텍처
|
||||||
|
- 기존 구조의 문제점 분석
|
||||||
|
- 개선된 모듈 구조 제안
|
||||||
|
- 의존성 최적화
|
||||||
|
|
||||||
|
### 3. 코드 품질 향상 전략
|
||||||
|
- 중복 코드 제거 방안
|
||||||
|
- 에러 처리 강화 방안
|
||||||
|
- 테스트 용이성 개선
|
||||||
|
|
||||||
|
### 4. 추가 기능 (필요시)
|
||||||
|
- 누락된 API 기능
|
||||||
|
- 유틸리티 함수
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
- 업데이트된 설계 문서 (poc/instagram/DESIGN.md 갱신)
|
||||||
|
- 리팩토링 변경 사항 요약
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5단계: `/develop` (리팩토링 개발)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 작업 개요
|
||||||
|
poc/instagram/ 코드 리팩토링 및 개선 구현
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
- 업데이트된 설계: poc/instagram/DESIGN.md
|
||||||
|
- 초기 리뷰 결과: poc/instagram/REVIEW_V1.md
|
||||||
|
- 기존 코드: poc/instagram/
|
||||||
|
|
||||||
|
## 리팩토링 요구사항
|
||||||
|
|
||||||
|
### 1. Critical/Major 이슈 수정
|
||||||
|
- 리뷰에서 지적된 심각한 문제 해결
|
||||||
|
- 보안 취약점 수정
|
||||||
|
|
||||||
|
### 2. 코드 품질 개선
|
||||||
|
- 중복 코드 제거
|
||||||
|
- 네이밍 개선
|
||||||
|
- 타입 힌트 보완
|
||||||
|
|
||||||
|
### 3. 에러 처리 강화
|
||||||
|
- 누락된 예외 처리 추가
|
||||||
|
- 에러 메시지 개선
|
||||||
|
- 재시도 로직 보완
|
||||||
|
|
||||||
|
### 4. 비동기 처리 최적화
|
||||||
|
- 리소스 관리 개선
|
||||||
|
- Context manager 적용
|
||||||
|
|
||||||
|
### 5. 문서화 보완
|
||||||
|
- README 업데이트
|
||||||
|
- docstring 보완
|
||||||
|
- 예제 코드 개선
|
||||||
|
|
||||||
|
## 코드 품질
|
||||||
|
- 모든 리뷰 피드백 반영
|
||||||
|
- 프로덕션 수준의 코드 품질
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6단계: `/review` (최종 리뷰)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 리뷰 대상
|
||||||
|
poc/instagram/ 폴더의 리팩토링된 최종 코드
|
||||||
|
|
||||||
|
## 리뷰 목적
|
||||||
|
- 초기 리뷰(REVIEW_V1.md) 피드백 반영 확인
|
||||||
|
- 최종 코드 품질 검증
|
||||||
|
- 프로덕션 배포 가능 여부 판단
|
||||||
|
|
||||||
|
## 리뷰 항목
|
||||||
|
|
||||||
|
### 1. 이전 리뷰 피드백 반영 확인
|
||||||
|
- REVIEW_V1.md의 Critical 이슈 해결 여부
|
||||||
|
- REVIEW_V1.md의 Major 이슈 해결 여부
|
||||||
|
- 개선 사항 적용 여부
|
||||||
|
|
||||||
|
### 2. 코드 품질 최종 검증
|
||||||
|
- 타입 힌트 완전성
|
||||||
|
- 코드 가독성
|
||||||
|
- 테스트 용이성
|
||||||
|
|
||||||
|
### 3. 아키텍처 최종 검증
|
||||||
|
- 모듈 구조 적절성
|
||||||
|
- 확장 가능성
|
||||||
|
- 유지보수성
|
||||||
|
|
||||||
|
### 4. 보안 최종 검증
|
||||||
|
- Credentials 관리
|
||||||
|
- 입력값 검증
|
||||||
|
- 로깅 보안
|
||||||
|
|
||||||
|
### 5. 문서화 최종 검증
|
||||||
|
- README 완성도
|
||||||
|
- 예제 실행 가능성
|
||||||
|
- API 문서화 수준
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
- 최종 리뷰 결과 (poc/instagram/REVIEW_FINAL.md)
|
||||||
|
- 잔여 이슈 목록 (있다면)
|
||||||
|
- 최종 승인 여부
|
||||||
|
- 향후 개선 제안 (Optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 기능 상세
|
||||||
|
|
||||||
|
### 1. 인증 (Authentication)
|
||||||
|
- Facebook App 기반 Access Token 관리
|
||||||
|
- Long-lived Token 교환
|
||||||
|
- Token 유효성 검증
|
||||||
|
|
||||||
|
### 2. 계정 정보 (Account)
|
||||||
|
- 비즈니스 계정 ID 조회
|
||||||
|
- 프로필 정보 조회 (username, followers_count, media_count 등)
|
||||||
|
|
||||||
|
### 3. 미디어 관리 (Media)
|
||||||
|
- 미디어 목록 조회 (피드)
|
||||||
|
- 단일 미디어 상세 조회
|
||||||
|
- 이미지/비디오 게시 (Container 생성 → 게시)
|
||||||
|
|
||||||
|
### 4. 인사이트 (Insights)
|
||||||
|
- 계정 인사이트 조회 (reach, impressions 등)
|
||||||
|
- 미디어별 인사이트 조회
|
||||||
|
|
||||||
|
### 5. 댓글 관리 (Comments)
|
||||||
|
- 댓글 목록 조회
|
||||||
|
- 댓글 답글 작성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 최종 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
poc/instagram/
|
||||||
|
├── __init__.py
|
||||||
|
├── config.py # Settings, 환경변수
|
||||||
|
├── client.py # InstagramGraphClient
|
||||||
|
├── models.py # Pydantic 모델
|
||||||
|
├── exceptions.py # 커스텀 예외
|
||||||
|
├── examples/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── auth_example.py
|
||||||
|
│ ├── account_example.py
|
||||||
|
│ ├── media_example.py
|
||||||
|
│ ├── insights_example.py
|
||||||
|
│ └── comments_example.py
|
||||||
|
├── DESIGN.md # 설계 문서
|
||||||
|
├── REVIEW_V1.md # 초기 리뷰 결과
|
||||||
|
├── REVIEW_FINAL.md # 최종 리뷰 결과
|
||||||
|
└── README.md # 사용 가이드
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,817 @@
|
||||||
|
# Instagram Graph API POC 설계 문서
|
||||||
|
|
||||||
|
## 📋 1. 요구사항 요약
|
||||||
|
|
||||||
|
### 1.1 기능적 요구사항
|
||||||
|
|
||||||
|
| 기능 | 설명 | 우선순위 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 인증 | Access Token 관리, Long-lived Token 교환, Token 검증 | 필수 |
|
||||||
|
| 계정 정보 | 비즈니스 계정 ID 조회, 프로필 정보 조회 | 필수 |
|
||||||
|
| 미디어 관리 | 목록/상세 조회, 이미지/비디오 게시 (Container → Publish) | 필수 |
|
||||||
|
| 인사이트 | 계정/미디어별 인사이트 조회 | 필수 |
|
||||||
|
| 댓글 관리 | 댓글 조회, 답글 작성 | 필수 |
|
||||||
|
|
||||||
|
### 1.2 비기능적 요구사항
|
||||||
|
|
||||||
|
- **비동기 처리**: 모든 API 호출은 async/await 사용
|
||||||
|
- **Rate Limit 준수**: 시간당 200 요청 제한, 429 에러 시 지수 백오프
|
||||||
|
- **에러 처리**: 체계적인 예외 계층 구조
|
||||||
|
- **로깅**: 요청/응답 추적 가능
|
||||||
|
- **보안**: Credentials 환경변수 관리, 민감정보 로깅 방지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 2. 설계 개요
|
||||||
|
|
||||||
|
### 2.1 아키텍처 다이어그램
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ examples/ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ auth_example │ │media_example │ │insights_example│ │
|
||||||
|
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||||
|
└─────────┼────────────────┼────────────────┼─────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ InstagramGraphClient │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ - _request() : 공통 HTTP 요청 (재시도, 로깅) │ │
|
||||||
|
│ │ - debug_token() : 토큰 검증 │ │
|
||||||
|
│ │ - exchange_token() : Long-lived 토큰 교환 │ │
|
||||||
|
│ │ - get_account() : 계정 정보 조회 │ │
|
||||||
|
│ │ - get_media_list() : 미디어 목록 │ │
|
||||||
|
│ │ - get_media() : 미디어 상세 │ │
|
||||||
|
│ │ - publish_image() : 이미지 게시 │ │
|
||||||
|
│ │ - publish_video() : 비디오 게시 │ │
|
||||||
|
│ │ - get_account_insights() : 계정 인사이트 │ │
|
||||||
|
│ │ - get_media_insights() : 미디어 인사이트 │ │
|
||||||
|
│ │ - get_comments() : 댓글 조회 │ │
|
||||||
|
│ │ - reply_comment() : 댓글 답글 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ models.py │ │ exceptions.py │ │ config.py │
|
||||||
|
│ (Pydantic v2) │ │ (커스텀 예외) │ │ (Settings) │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 모듈 의존성 관계
|
||||||
|
|
||||||
|
```
|
||||||
|
config.py ◄─────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
exceptions.py ◄─────────────────────┐ │
|
||||||
|
│ │ │
|
||||||
|
▼ │ │
|
||||||
|
models.py ◄──────────────┐ │ │
|
||||||
|
│ │ │ │
|
||||||
|
▼ │ │ │
|
||||||
|
client.py ────────────────┴──────────┴───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
examples/ (사용 예제)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 3. API 설계 (Instagram Graph API 엔드포인트)
|
||||||
|
|
||||||
|
### 3.1 Base URL
|
||||||
|
```
|
||||||
|
https://graph.instagram.com (Instagram Platform API)
|
||||||
|
https://graph.facebook.com/v21.0 (Facebook Graph API - 일부 기능)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 인증 API
|
||||||
|
|
||||||
|
#### 3.2.1 토큰 검증 (Debug Token)
|
||||||
|
```
|
||||||
|
GET /debug_token
|
||||||
|
?input_token={access_token}
|
||||||
|
&access_token={app_id}|{app_secret}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"app_id": "123456789",
|
||||||
|
"type": "USER",
|
||||||
|
"application": "App Name",
|
||||||
|
"expires_at": 1234567890,
|
||||||
|
"is_valid": true,
|
||||||
|
"scopes": ["instagram_basic", "instagram_content_publish"],
|
||||||
|
"user_id": "17841400000000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.2 Long-lived Token 교환
|
||||||
|
```
|
||||||
|
GET /access_token
|
||||||
|
?grant_type=ig_exchange_token
|
||||||
|
&client_secret={app_secret}
|
||||||
|
&access_token={short_lived_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"access_token": "IGQVJ...",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 5184000 // 60일 (초)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.3 토큰 갱신
|
||||||
|
```
|
||||||
|
GET /refresh_access_token
|
||||||
|
?grant_type=ig_refresh_token
|
||||||
|
&access_token={long_lived_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"access_token": "IGQVJ...",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 5184000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 계정 API
|
||||||
|
|
||||||
|
#### 3.3.1 계정 정보 조회
|
||||||
|
```
|
||||||
|
GET /me
|
||||||
|
?fields=id,username,name,account_type,profile_picture_url,followers_count,follows_count,media_count
|
||||||
|
&access_token={access_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"id": "17841400000000000",
|
||||||
|
"username": "example_user",
|
||||||
|
"name": "Example User",
|
||||||
|
"account_type": "BUSINESS",
|
||||||
|
"profile_picture_url": "https://...",
|
||||||
|
"followers_count": 1000,
|
||||||
|
"follows_count": 500,
|
||||||
|
"media_count": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 미디어 API
|
||||||
|
|
||||||
|
#### 3.4.1 미디어 목록 조회
|
||||||
|
```
|
||||||
|
GET /{ig-user-id}/media
|
||||||
|
?fields=id,media_type,media_url,thumbnail_url,caption,timestamp,permalink,like_count,comments_count
|
||||||
|
&limit=25
|
||||||
|
&access_token={access_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "17880000000000000",
|
||||||
|
"media_type": "IMAGE",
|
||||||
|
"media_url": "https://...",
|
||||||
|
"caption": "My photo",
|
||||||
|
"timestamp": "2024-01-01T00:00:00+0000",
|
||||||
|
"permalink": "https://www.instagram.com/p/...",
|
||||||
|
"like_count": 100,
|
||||||
|
"comments_count": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paging": {
|
||||||
|
"cursors": {
|
||||||
|
"before": "...",
|
||||||
|
"after": "..."
|
||||||
|
},
|
||||||
|
"next": "https://graph.instagram.com/..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.4.2 미디어 상세 조회
|
||||||
|
```
|
||||||
|
GET /{ig-media-id}
|
||||||
|
?fields=id,media_type,media_url,thumbnail_url,caption,timestamp,permalink,like_count,comments_count,children{id,media_type,media_url}
|
||||||
|
&access_token={access_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.4.3 이미지 게시 (2단계 프로세스)
|
||||||
|
|
||||||
|
**Step 1: Container 생성**
|
||||||
|
```
|
||||||
|
POST /{ig-user-id}/media
|
||||||
|
?image_url={public_image_url}
|
||||||
|
&caption={caption_text}
|
||||||
|
&access_token={access_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"id": "17889000000000000" // container_id
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Container 상태 확인**
|
||||||
|
```
|
||||||
|
GET /{container-id}
|
||||||
|
?fields=status_code,status
|
||||||
|
&access_token={access_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"status_code": "FINISHED", // IN_PROGRESS, FINISHED, ERROR
|
||||||
|
"id": "17889000000000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 게시**
|
||||||
|
```
|
||||||
|
POST /{ig-user-id}/media_publish
|
||||||
|
?creation_id={container_id}
|
||||||
|
&access_token={access_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"id": "17880000000000001" // 게시된 media_id
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.4.4 비디오 게시 (3단계 프로세스)
|
||||||
|
|
||||||
|
**Step 1: Container 생성**
|
||||||
|
```
|
||||||
|
POST /{ig-user-id}/media
|
||||||
|
?media_type=REELS
|
||||||
|
&video_url={public_video_url}
|
||||||
|
&caption={caption_text}
|
||||||
|
&share_to_feed=true
|
||||||
|
&access_token={access_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"id": "17889000000000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 & 3**: 이미지와 동일 (상태 확인 → 게시)
|
||||||
|
|
||||||
|
### 3.5 인사이트 API
|
||||||
|
|
||||||
|
#### 3.5.1 계정 인사이트
|
||||||
|
```
|
||||||
|
GET /{ig-user-id}/insights
|
||||||
|
?metric=impressions,reach,profile_views,accounts_engaged
|
||||||
|
&period=day
|
||||||
|
&metric_type=total_value
|
||||||
|
&access_token={access_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"name": "impressions",
|
||||||
|
"period": "day",
|
||||||
|
"values": [{"value": 1000}],
|
||||||
|
"title": "Impressions",
|
||||||
|
"description": "Total number of times..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.5.2 미디어 인사이트
|
||||||
|
```
|
||||||
|
GET /{ig-media-id}/insights
|
||||||
|
?metric=impressions,reach,engagement,saved
|
||||||
|
&access_token={access_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"name": "impressions",
|
||||||
|
"period": "lifetime",
|
||||||
|
"values": [{"value": 500}],
|
||||||
|
"title": "Impressions"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 댓글 API
|
||||||
|
|
||||||
|
#### 3.6.1 댓글 목록 조회
|
||||||
|
```
|
||||||
|
GET /{ig-media-id}/comments
|
||||||
|
?fields=id,text,username,timestamp,like_count,replies{id,text,username,timestamp}
|
||||||
|
&access_token={access_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "17890000000000000",
|
||||||
|
"text": "Great photo!",
|
||||||
|
"username": "commenter",
|
||||||
|
"timestamp": "2024-01-01T12:00:00+0000",
|
||||||
|
"like_count": 5,
|
||||||
|
"replies": {
|
||||||
|
"data": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.6.2 댓글 답글 작성
|
||||||
|
```
|
||||||
|
POST /{ig-comment-id}/replies
|
||||||
|
?message={reply_text}
|
||||||
|
&access_token={access_token}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"id": "17890000000000001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 4. 데이터 모델 (Pydantic v2)
|
||||||
|
|
||||||
|
### 4.1 인증 모델
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TokenInfo(BaseModel):
|
||||||
|
"""토큰 정보"""
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int # 초 단위
|
||||||
|
|
||||||
|
class TokenDebugData(BaseModel):
|
||||||
|
"""토큰 디버그 정보"""
|
||||||
|
app_id: str
|
||||||
|
type: str
|
||||||
|
application: str
|
||||||
|
expires_at: int # Unix timestamp
|
||||||
|
is_valid: bool
|
||||||
|
scopes: list[str]
|
||||||
|
user_id: str
|
||||||
|
|
||||||
|
class TokenDebugResponse(BaseModel):
|
||||||
|
"""토큰 디버그 응답"""
|
||||||
|
data: TokenDebugData
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 계정 모델
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Account(BaseModel):
|
||||||
|
"""Instagram 비즈니스 계정"""
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
account_type: str # BUSINESS, CREATOR
|
||||||
|
profile_picture_url: Optional[str] = None
|
||||||
|
followers_count: int = 0
|
||||||
|
follows_count: int = 0
|
||||||
|
media_count: int = 0
|
||||||
|
biography: Optional[str] = None
|
||||||
|
website: Optional[str] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 미디어 모델
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MediaType(str, Enum):
|
||||||
|
"""미디어 타입"""
|
||||||
|
IMAGE = "IMAGE"
|
||||||
|
VIDEO = "VIDEO"
|
||||||
|
CAROUSEL_ALBUM = "CAROUSEL_ALBUM"
|
||||||
|
REELS = "REELS"
|
||||||
|
|
||||||
|
class Media(BaseModel):
|
||||||
|
"""미디어 정보"""
|
||||||
|
id: str
|
||||||
|
media_type: MediaType
|
||||||
|
media_url: Optional[str] = None
|
||||||
|
thumbnail_url: Optional[str] = None
|
||||||
|
caption: Optional[str] = None
|
||||||
|
timestamp: datetime
|
||||||
|
permalink: str
|
||||||
|
like_count: int = 0
|
||||||
|
comments_count: int = 0
|
||||||
|
children: Optional[list["Media"]] = None # 캐러셀용
|
||||||
|
|
||||||
|
class MediaContainer(BaseModel):
|
||||||
|
"""미디어 컨테이너 (게시 전 상태)"""
|
||||||
|
id: str
|
||||||
|
status_code: Optional[str] = None # IN_PROGRESS, FINISHED, ERROR
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
class MediaList(BaseModel):
|
||||||
|
"""미디어 목록 응답"""
|
||||||
|
data: list[Media]
|
||||||
|
paging: Optional[Paging] = None
|
||||||
|
|
||||||
|
class Paging(BaseModel):
|
||||||
|
"""페이징 정보"""
|
||||||
|
cursors: Optional[dict[str, str]] = None
|
||||||
|
next: Optional[str] = None
|
||||||
|
previous: Optional[str] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 인사이트 모델
|
||||||
|
|
||||||
|
```python
|
||||||
|
class InsightValue(BaseModel):
|
||||||
|
"""인사이트 값"""
|
||||||
|
value: int
|
||||||
|
end_time: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Insight(BaseModel):
|
||||||
|
"""인사이트 정보"""
|
||||||
|
name: str
|
||||||
|
period: str # day, week, days_28, lifetime
|
||||||
|
values: list[InsightValue]
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
id: str
|
||||||
|
|
||||||
|
class InsightResponse(BaseModel):
|
||||||
|
"""인사이트 응답"""
|
||||||
|
data: list[Insight]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 댓글 모델
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Comment(BaseModel):
|
||||||
|
"""댓글 정보"""
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
username: str
|
||||||
|
timestamp: datetime
|
||||||
|
like_count: int = 0
|
||||||
|
replies: Optional["CommentList"] = None
|
||||||
|
|
||||||
|
class CommentList(BaseModel):
|
||||||
|
"""댓글 목록 응답"""
|
||||||
|
data: list[Comment]
|
||||||
|
paging: Optional[Paging] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 에러 모델
|
||||||
|
|
||||||
|
```python
|
||||||
|
class APIError(BaseModel):
|
||||||
|
"""Instagram API 에러 응답"""
|
||||||
|
message: str
|
||||||
|
type: str
|
||||||
|
code: int
|
||||||
|
error_subcode: Optional[int] = None
|
||||||
|
fbtrace_id: Optional[str] = None
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""에러 응답 래퍼"""
|
||||||
|
error: APIError
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 5. 예외 처리 전략
|
||||||
|
|
||||||
|
### 5.1 예외 계층 구조
|
||||||
|
|
||||||
|
```python
|
||||||
|
class InstagramAPIError(Exception):
|
||||||
|
"""Instagram API 기본 예외"""
|
||||||
|
def __init__(self, message: str, code: int = None, subcode: int = None):
|
||||||
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
self.subcode = subcode
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
class AuthenticationError(InstagramAPIError):
|
||||||
|
"""인증 관련 에러 (토큰 만료, 무효 등)"""
|
||||||
|
# code: 190 (Invalid OAuth access token)
|
||||||
|
pass
|
||||||
|
|
||||||
|
class RateLimitError(InstagramAPIError):
|
||||||
|
"""Rate Limit 초과 (HTTP 429)"""
|
||||||
|
def __init__(self, message: str, retry_after: int = None):
|
||||||
|
super().__init__(message, code=4)
|
||||||
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
class PermissionError(InstagramAPIError):
|
||||||
|
"""권한 부족 에러"""
|
||||||
|
# code: 10 (Permission denied)
|
||||||
|
# code: 200 (Requires business account)
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MediaPublishError(InstagramAPIError):
|
||||||
|
"""미디어 게시 실패"""
|
||||||
|
# 이미지 URL 접근 불가, 포맷 오류 등
|
||||||
|
pass
|
||||||
|
|
||||||
|
class InvalidRequestError(InstagramAPIError):
|
||||||
|
"""잘못된 요청 (파라미터 오류 등)"""
|
||||||
|
# code: 100 (Invalid parameter)
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ResourceNotFoundError(InstagramAPIError):
|
||||||
|
"""리소스를 찾을 수 없음"""
|
||||||
|
# code: 803 (Object does not exist)
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 에러 코드 매핑
|
||||||
|
|
||||||
|
| API Error Code | Subcode | Exception Class | 설명 |
|
||||||
|
|----------------|---------|-----------------|------|
|
||||||
|
| 4 | - | RateLimitError | Rate limit 초과 |
|
||||||
|
| 10 | - | PermissionError | 권한 부족 |
|
||||||
|
| 100 | - | InvalidRequestError | 잘못된 파라미터 |
|
||||||
|
| 190 | 458 | AuthenticationError | 앱 권한 없음 |
|
||||||
|
| 190 | 463 | AuthenticationError | 토큰 만료 |
|
||||||
|
| 190 | 467 | AuthenticationError | 유효하지 않은 토큰 |
|
||||||
|
| 200 | - | PermissionError | 비즈니스 계정 필요 |
|
||||||
|
| 803 | - | ResourceNotFoundError | 리소스 없음 |
|
||||||
|
|
||||||
|
### 5.3 재시도 전략
|
||||||
|
|
||||||
|
```python
|
||||||
|
RETRY_CONFIG = {
|
||||||
|
"max_retries": 3,
|
||||||
|
"base_delay": 1.0, # 초
|
||||||
|
"max_delay": 60.0, # 초
|
||||||
|
"exponential_base": 2,
|
||||||
|
"retryable_status_codes": [429, 500, 502, 503, 504],
|
||||||
|
"retryable_error_codes": [4, 17, 341], # Rate limit 관련
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 6. 클라이언트 인터페이스
|
||||||
|
|
||||||
|
### 6.1 InstagramGraphClient 클래스
|
||||||
|
|
||||||
|
```python
|
||||||
|
class InstagramGraphClient:
|
||||||
|
"""Instagram Graph API 클라이언트"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
access_token: str,
|
||||||
|
app_id: Optional[str] = None,
|
||||||
|
app_secret: Optional[str] = None,
|
||||||
|
api_version: str = "v21.0",
|
||||||
|
timeout: float = 30.0,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
access_token: Instagram 액세스 토큰
|
||||||
|
app_id: Facebook 앱 ID (토큰 검증 시 필요)
|
||||||
|
app_secret: Facebook 앱 시크릿 (토큰 교환 시 필요)
|
||||||
|
api_version: Graph API 버전
|
||||||
|
timeout: HTTP 요청 타임아웃 (초)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "InstagramGraphClient":
|
||||||
|
"""비동기 컨텍스트 매니저 진입"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aexit__(self, *args) -> None:
|
||||||
|
"""비동기 컨텍스트 매니저 종료"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ==================== 인증 ====================
|
||||||
|
|
||||||
|
async def debug_token(self) -> TokenDebugResponse:
|
||||||
|
"""현재 토큰 정보 조회 (유효성 검증)"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def exchange_long_lived_token(self) -> TokenInfo:
|
||||||
|
"""단기 토큰을 장기 토큰(60일)으로 교환"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def refresh_token(self) -> TokenInfo:
|
||||||
|
"""장기 토큰 갱신"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ==================== 계정 ====================
|
||||||
|
|
||||||
|
async def get_account(self) -> Account:
|
||||||
|
"""현재 계정 정보 조회"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_account_id(self) -> str:
|
||||||
|
"""현재 계정 ID만 조회"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ==================== 미디어 ====================
|
||||||
|
|
||||||
|
async def get_media_list(
|
||||||
|
self,
|
||||||
|
limit: int = 25,
|
||||||
|
after: Optional[str] = None,
|
||||||
|
) -> MediaList:
|
||||||
|
"""미디어 목록 조회 (페이지네이션 지원)"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_media(self, media_id: str) -> Media:
|
||||||
|
"""미디어 상세 조회"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def publish_image(
|
||||||
|
self,
|
||||||
|
image_url: str,
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
) -> Media:
|
||||||
|
"""이미지 게시 (Container 생성 → 상태 확인 → 게시)"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def publish_video(
|
||||||
|
self,
|
||||||
|
video_url: str,
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
share_to_feed: bool = True,
|
||||||
|
) -> Media:
|
||||||
|
"""비디오/릴스 게시"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def publish_carousel(
|
||||||
|
self,
|
||||||
|
media_urls: list[str],
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
) -> Media:
|
||||||
|
"""캐러셀(멀티 이미지) 게시"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ==================== 인사이트 ====================
|
||||||
|
|
||||||
|
async def get_account_insights(
|
||||||
|
self,
|
||||||
|
metrics: list[str],
|
||||||
|
period: str = "day",
|
||||||
|
) -> InsightResponse:
|
||||||
|
"""계정 인사이트 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metrics: 조회할 메트릭 목록
|
||||||
|
- impressions, reach, profile_views, accounts_engaged 등
|
||||||
|
period: 기간 (day, week, days_28)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_media_insights(
|
||||||
|
self,
|
||||||
|
media_id: str,
|
||||||
|
metrics: Optional[list[str]] = None,
|
||||||
|
) -> InsightResponse:
|
||||||
|
"""미디어 인사이트 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media_id: 미디어 ID
|
||||||
|
metrics: 조회할 메트릭 (기본: impressions, reach, engagement, saved)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ==================== 댓글 ====================
|
||||||
|
|
||||||
|
async def get_comments(
|
||||||
|
self,
|
||||||
|
media_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> CommentList:
|
||||||
|
"""미디어의 댓글 목록 조회"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def reply_comment(
|
||||||
|
self,
|
||||||
|
comment_id: str,
|
||||||
|
message: str,
|
||||||
|
) -> Comment:
|
||||||
|
"""댓글에 답글 작성"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ==================== 내부 메서드 ====================
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
params: Optional[dict] = None,
|
||||||
|
data: Optional[dict] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
공통 HTTP 요청 처리
|
||||||
|
- Rate Limit 시 지수 백오프 재시도
|
||||||
|
- 에러 응답 → 커스텀 예외 변환
|
||||||
|
- 요청/응답 로깅
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _wait_for_container(
|
||||||
|
self,
|
||||||
|
container_id: str,
|
||||||
|
timeout: float = 60.0,
|
||||||
|
poll_interval: float = 2.0,
|
||||||
|
) -> MediaContainer:
|
||||||
|
"""컨테이너 상태가 FINISHED가 될 때까지 대기"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 7. 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
poc/instagram/
|
||||||
|
├── __init__.py # 패키지 초기화 및 public API export
|
||||||
|
├── config.py # Settings (환경변수 관리)
|
||||||
|
├── exceptions.py # 커스텀 예외 클래스
|
||||||
|
├── models.py # Pydantic v2 데이터 모델
|
||||||
|
├── client.py # InstagramGraphClient
|
||||||
|
├── examples/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── auth_example.py # 토큰 검증, 교환 예제
|
||||||
|
│ ├── account_example.py # 계정 정보 조회 예제
|
||||||
|
│ ├── media_example.py # 미디어 조회/게시 예제
|
||||||
|
│ ├── insights_example.py # 인사이트 조회 예제
|
||||||
|
│ └── comments_example.py # 댓글 조회/답글 예제
|
||||||
|
├── DESIGN.md # 설계 문서 (본 문서)
|
||||||
|
└── README.md # 사용 가이드
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.1 각 파일의 역할
|
||||||
|
|
||||||
|
| 파일 | 역할 | 의존성 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `config.py` | 환경변수 로드, API 설정 | pydantic-settings |
|
||||||
|
| `exceptions.py` | 커스텀 예외 정의 | - |
|
||||||
|
| `models.py` | API 요청/응답 Pydantic 모델 | pydantic |
|
||||||
|
| `client.py` | Instagram Graph API 클라이언트 | httpx, 위 모듈들 |
|
||||||
|
| `examples/*.py` | 실행 가능한 예제 코드 | client.py |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 8. 구현 순서
|
||||||
|
|
||||||
|
개발 에이전트가 따라야 할 순서:
|
||||||
|
|
||||||
|
### Phase 1: 기반 모듈 (의존성 없음)
|
||||||
|
1. `config.py` - Settings 클래스
|
||||||
|
2. `exceptions.py` - 예외 클래스 계층
|
||||||
|
|
||||||
|
### Phase 2: 데이터 모델
|
||||||
|
3. `models.py` - Pydantic 모델 (Token, Account, Media, Insight, Comment)
|
||||||
|
|
||||||
|
### Phase 3: 클라이언트 구현
|
||||||
|
4. `client.py` - InstagramGraphClient
|
||||||
|
- 4.1: 기본 구조 및 `_request()` 메서드
|
||||||
|
- 4.2: 인증 메서드 (debug_token, exchange_token, refresh_token)
|
||||||
|
- 4.3: 계정 메서드 (get_account, get_account_id)
|
||||||
|
- 4.4: 미디어 메서드 (get_media_list, get_media, publish_image, publish_video)
|
||||||
|
- 4.5: 인사이트 메서드 (get_account_insights, get_media_insights)
|
||||||
|
- 4.6: 댓글 메서드 (get_comments, reply_comment)
|
||||||
|
|
||||||
|
### Phase 4: 예제 및 문서
|
||||||
|
5. `examples/auth_example.py`
|
||||||
|
6. `examples/account_example.py`
|
||||||
|
7. `examples/media_example.py`
|
||||||
|
8. `examples/insights_example.py`
|
||||||
|
9. `examples/comments_example.py`
|
||||||
|
10. `README.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 9. 설계 검수 결과
|
||||||
|
|
||||||
|
### 검수 체크리스트
|
||||||
|
|
||||||
|
- [x] **기존 프로젝트 패턴과 일관성** - 3계층 구조, Pydantic v2, 비동기 패턴 적용
|
||||||
|
- [x] **비동기 처리** - httpx.AsyncClient, async/await 전체 적용
|
||||||
|
- [x] **N+1 쿼리 문제** - 해당 없음 (외부 API 호출)
|
||||||
|
- [x] **트랜잭션 경계** - 해당 없음 (DB 미사용)
|
||||||
|
- [x] **예외 처리 전략** - 계층화된 예외, 에러 코드 매핑, 재시도 로직
|
||||||
|
- [x] **확장성** - 새 엔드포인트 추가 용이, 모델 확장 가능
|
||||||
|
- [x] **직관적 구조** - 명확한 모듈 분리, 일관된 네이밍
|
||||||
|
- [x] **SOLID 원칙** - 단일 책임, 개방-폐쇄 원칙 준수
|
||||||
|
|
||||||
|
### 참고 문서
|
||||||
|
|
||||||
|
- [Instagram Graph API 공식 가이드](https://elfsight.com/blog/instagram-graph-api-complete-developer-guide-for-2025/)
|
||||||
|
- [Instagram Platform API 구현 가이드](https://gist.github.com/PrenSJ2/0213e60e834e66b7e09f7f93999163fc)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 다음 단계
|
||||||
|
|
||||||
|
설계가 완료되었습니다. `/develop` 명령으로 개발 에이전트를 호출하여 구현을 진행합니다.
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
# Instagram Graph API POC - 리팩토링 설계 문서 (V2)
|
||||||
|
|
||||||
|
**작성일**: 2026-01-29
|
||||||
|
**기반**: REVIEW_V1.md 분석 결과
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 리뷰 기반 개선 요약
|
||||||
|
|
||||||
|
### Critical 이슈 (3건)
|
||||||
|
|
||||||
|
| 이슈 | 현재 상태 | 개선 방향 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| `PermissionError` 섀도잉 | Python 내장 예외와 이름 충돌 | `InstagramPermissionError`로 변경 |
|
||||||
|
| 토큰 노출 위험 | 로그에 토큰이 그대로 기록 | 마스킹 유틸리티 추가 |
|
||||||
|
| 싱글톤 설정 | 테스트 시 모킹 어려움 | 팩토리 패턴 적용 |
|
||||||
|
|
||||||
|
### Major 이슈 (6건)
|
||||||
|
|
||||||
|
| 이슈 | 현재 상태 | 개선 방향 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 시간 계산 정확도 | `elapsed += interval` | `time.monotonic()` 사용 |
|
||||||
|
| 타임존 미처리 | naive datetime | UTC 명시적 처리 |
|
||||||
|
| 캐러셀 순차 처리 | 이미지별 직렬 생성 | `asyncio.gather()` 병렬화 |
|
||||||
|
| 생성자 예외 | `__init__`에서 예외 발생 | validate 메서드 분리 |
|
||||||
|
| 서브코드 미활용 | code만 매핑 | (code, subcode) 튜플 매핑 |
|
||||||
|
| JSON 파싱 에러 | 무조건 파싱 시도 | try-except 처리 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 개선 설계
|
||||||
|
|
||||||
|
### 2.1 예외 클래스 이름 변경
|
||||||
|
|
||||||
|
```python
|
||||||
|
# exceptions.py - 변경 전
|
||||||
|
class PermissionError(InstagramAPIError): ...
|
||||||
|
|
||||||
|
# exceptions.py - 변경 후
|
||||||
|
class InstagramPermissionError(InstagramAPIError):
|
||||||
|
"""
|
||||||
|
권한 부족 에러
|
||||||
|
|
||||||
|
Python 내장 PermissionError와 구분하기 위해 접두사 사용
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 하위 호환성을 위한 alias (deprecated)
|
||||||
|
PermissionError = InstagramPermissionError # deprecated alias
|
||||||
|
```
|
||||||
|
|
||||||
|
**영향 범위**:
|
||||||
|
- `exceptions.py`: 클래스명 변경
|
||||||
|
- `__init__.py`: export 업데이트
|
||||||
|
- `client.py`: import 업데이트
|
||||||
|
|
||||||
|
### 2.2 토큰 마스킹 유틸리티
|
||||||
|
|
||||||
|
```python
|
||||||
|
# client.py에 추가
|
||||||
|
def _mask_sensitive_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
로깅용 파라미터에서 민감 정보 마스킹
|
||||||
|
|
||||||
|
Args:
|
||||||
|
params: 원본 파라미터
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
마스킹된 파라미터 복사본
|
||||||
|
"""
|
||||||
|
SENSITIVE_KEYS = {"access_token", "client_secret", "input_token"}
|
||||||
|
masked = params.copy()
|
||||||
|
|
||||||
|
for key in SENSITIVE_KEYS:
|
||||||
|
if key in masked and masked[key]:
|
||||||
|
value = str(masked[key])
|
||||||
|
if len(value) > 14:
|
||||||
|
masked[key] = f"{value[:10]}...{value[-4:]}"
|
||||||
|
else:
|
||||||
|
masked[key] = "***"
|
||||||
|
|
||||||
|
return masked
|
||||||
|
```
|
||||||
|
|
||||||
|
**적용 위치**:
|
||||||
|
- `_request()` 메서드의 디버그 로깅
|
||||||
|
|
||||||
|
### 2.3 설정 팩토리 패턴
|
||||||
|
|
||||||
|
```python
|
||||||
|
# config.py - 변경
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
class InstagramSettings(BaseSettings):
|
||||||
|
# ... 기존 코드 유지 ...
|
||||||
|
pass
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> InstagramSettings:
|
||||||
|
"""
|
||||||
|
설정 인스턴스 반환 (캐싱됨)
|
||||||
|
|
||||||
|
테스트 시 캐시 초기화:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
"""
|
||||||
|
return InstagramSettings()
|
||||||
|
|
||||||
|
# 하위 호환성을 위한 기본 인스턴스
|
||||||
|
# @deprecated: get_settings() 사용 권장
|
||||||
|
settings = get_settings()
|
||||||
|
```
|
||||||
|
|
||||||
|
**장점**:
|
||||||
|
- 테스트 시 `get_settings.cache_clear()` 호출로 모킹 가능
|
||||||
|
- 기존 코드 호환성 유지
|
||||||
|
|
||||||
|
### 2.4 시간 계산 정확도 개선
|
||||||
|
|
||||||
|
```python
|
||||||
|
# client.py - _wait_for_container 메서드 개선
|
||||||
|
import time
|
||||||
|
|
||||||
|
async def _wait_for_container(
|
||||||
|
self,
|
||||||
|
container_id: str,
|
||||||
|
timeout: Optional[float] = None,
|
||||||
|
poll_interval: Optional[float] = None,
|
||||||
|
) -> MediaContainer:
|
||||||
|
timeout = timeout or self.settings.container_timeout
|
||||||
|
poll_interval = poll_interval or self.settings.container_poll_interval
|
||||||
|
|
||||||
|
start_time = time.monotonic() # 변경: 정확한 시간 측정
|
||||||
|
|
||||||
|
while True:
|
||||||
|
elapsed = time.monotonic() - start_time
|
||||||
|
if elapsed >= timeout:
|
||||||
|
raise ContainerTimeoutError(
|
||||||
|
f"컨테이너 처리 타임아웃 ({timeout}초 초과): {container_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... 상태 확인 로직 ...
|
||||||
|
|
||||||
|
await asyncio.sleep(poll_interval)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 타임존 처리
|
||||||
|
|
||||||
|
```python
|
||||||
|
# models.py - TokenDebugData 개선
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
class TokenDebugData(BaseModel):
|
||||||
|
# ... 기존 필드 ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expires_at_datetime(self) -> datetime:
|
||||||
|
"""만료 시각을 UTC datetime으로 변환"""
|
||||||
|
return datetime.fromtimestamp(self.expires_at, tz=timezone.utc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""토큰 만료 여부 확인 (UTC 기준)"""
|
||||||
|
return datetime.now(timezone.utc).timestamp() > self.expires_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 캐러셀 병렬 처리
|
||||||
|
|
||||||
|
```python
|
||||||
|
# client.py - publish_carousel 개선
|
||||||
|
async def publish_carousel(
|
||||||
|
self,
|
||||||
|
media_urls: list[str],
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
) -> Media:
|
||||||
|
if len(media_urls) < 2 or len(media_urls) > 10:
|
||||||
|
raise ValueError("캐러셀은 2-10개의 이미지가 필요합니다.")
|
||||||
|
|
||||||
|
account_id = await self.get_account_id()
|
||||||
|
|
||||||
|
# Step 1: 각 이미지의 Container 병렬 생성
|
||||||
|
async def create_item_container(url: str) -> str:
|
||||||
|
container_url = self.settings.get_instagram_url(f"{account_id}/media")
|
||||||
|
response = await self._request(
|
||||||
|
method="POST",
|
||||||
|
url=container_url,
|
||||||
|
params={"image_url": url, "is_carousel_item": "true"},
|
||||||
|
)
|
||||||
|
return response["id"]
|
||||||
|
|
||||||
|
logger.debug(f"[publish_carousel] Step 1: {len(media_urls)}개 Container 병렬 생성")
|
||||||
|
children_ids = await asyncio.gather(
|
||||||
|
*[create_item_container(url) for url in media_urls]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... 이후 동일 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 에러 코드 매핑 확장
|
||||||
|
|
||||||
|
```python
|
||||||
|
# exceptions.py - 서브코드 매핑 추가
|
||||||
|
# 기본 코드 매핑
|
||||||
|
ERROR_CODE_MAPPING: dict[int, type[InstagramAPIError]] = {
|
||||||
|
4: RateLimitError,
|
||||||
|
10: InstagramPermissionError,
|
||||||
|
17: RateLimitError,
|
||||||
|
100: InvalidRequestError,
|
||||||
|
190: AuthenticationError,
|
||||||
|
200: InstagramPermissionError,
|
||||||
|
230: InstagramPermissionError,
|
||||||
|
341: RateLimitError,
|
||||||
|
803: ResourceNotFoundError,
|
||||||
|
}
|
||||||
|
|
||||||
|
# (code, subcode) 세부 매핑
|
||||||
|
ERROR_CODE_SUBCODE_MAPPING: dict[tuple[int, int], type[InstagramAPIError]] = {
|
||||||
|
(100, 33): ResourceNotFoundError, # Object does not exist
|
||||||
|
(190, 458): AuthenticationError, # App not authorized
|
||||||
|
(190, 463): AuthenticationError, # Token expired
|
||||||
|
(190, 467): AuthenticationError, # Invalid token
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_exception_from_error(...) -> InstagramAPIError:
|
||||||
|
# 먼저 (code, subcode) 조합 확인
|
||||||
|
if code is not None and subcode is not None:
|
||||||
|
key = (code, subcode)
|
||||||
|
if key in ERROR_CODE_SUBCODE_MAPPING:
|
||||||
|
exception_class = ERROR_CODE_SUBCODE_MAPPING[key]
|
||||||
|
return exception_class(...)
|
||||||
|
|
||||||
|
# 기본 코드 매핑
|
||||||
|
if code is not None:
|
||||||
|
exception_class = ERROR_CODE_MAPPING.get(code, InstagramAPIError)
|
||||||
|
else:
|
||||||
|
exception_class = InstagramAPIError
|
||||||
|
|
||||||
|
return exception_class(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.8 JSON 파싱 안전 처리
|
||||||
|
|
||||||
|
```python
|
||||||
|
# client.py - _request 메서드 개선
|
||||||
|
async def _request(...) -> dict[str, Any]:
|
||||||
|
# ... 기존 코드 ...
|
||||||
|
|
||||||
|
# JSON 파싱 (안전 처리)
|
||||||
|
try:
|
||||||
|
response_data = response.json()
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[_request] JSON 파싱 실패: status={response.status_code}, "
|
||||||
|
f"body={response.text[:200]}"
|
||||||
|
)
|
||||||
|
raise InstagramAPIError(
|
||||||
|
f"API 응답 파싱 실패: {e}",
|
||||||
|
code=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... 이후 동일 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 파일 변경 계획
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 변경 내용 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| `config.py` | 수정 | 팩토리 패턴 추가 (`get_settings()`) |
|
||||||
|
| `exceptions.py` | 수정 | `PermissionError` → `InstagramPermissionError`, 서브코드 매핑 추가 |
|
||||||
|
| `models.py` | 수정 | 타임존 처리 추가 |
|
||||||
|
| `client.py` | 수정 | 토큰 마스킹, 시간 계산, 병렬 처리, JSON 안전 파싱 |
|
||||||
|
| `__init__.py` | 수정 | export 업데이트 (`InstagramPermissionError`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 구현 순서
|
||||||
|
|
||||||
|
1. **exceptions.py** 수정 (의존성 없음)
|
||||||
|
- `InstagramPermissionError` 이름 변경
|
||||||
|
- 서브코드 매핑 추가
|
||||||
|
- deprecated alias 추가
|
||||||
|
|
||||||
|
2. **config.py** 수정
|
||||||
|
- `get_settings()` 팩토리 함수 추가
|
||||||
|
- 기존 `settings` 변수 유지 (하위 호환)
|
||||||
|
|
||||||
|
3. **models.py** 수정
|
||||||
|
- `TokenDebugData` 타임존 처리
|
||||||
|
|
||||||
|
4. **client.py** 수정
|
||||||
|
- `_mask_sensitive_params()` 추가
|
||||||
|
- `_request()` 로깅 개선
|
||||||
|
- `_wait_for_container()` 시간 계산 개선
|
||||||
|
- `publish_carousel()` 병렬 처리
|
||||||
|
- JSON 파싱 안전 처리
|
||||||
|
|
||||||
|
5. **__init__.py** 수정
|
||||||
|
- `InstagramPermissionError` export 추가
|
||||||
|
- `PermissionError` deprecated 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 하위 호환성 보장
|
||||||
|
|
||||||
|
| 변경 | 호환성 유지 방법 |
|
||||||
|
|------|-----------------|
|
||||||
|
| `PermissionError` 이름 | alias로 기존 이름 유지, deprecation 경고 추가 |
|
||||||
|
| `settings` 싱글톤 | 기존 변수 유지, 새 방식 문서화 |
|
||||||
|
| 동작 변경 없음 | 내부 최적화만, API 동일 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 가능성 개선
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 테스트 예시
|
||||||
|
import pytest
|
||||||
|
from poc.instagram.config import get_settings
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings(monkeypatch):
|
||||||
|
"""설정 모킹 fixture"""
|
||||||
|
monkeypatch.setenv("INSTAGRAM_ACCESS_TOKEN", "test_token")
|
||||||
|
monkeypatch.setenv("INSTAGRAM_APP_ID", "test_app_id")
|
||||||
|
get_settings.cache_clear() # 캐시 초기화
|
||||||
|
yield get_settings()
|
||||||
|
get_settings.cache_clear() # 정리
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 다음 단계
|
||||||
|
|
||||||
|
이 설계를 바탕으로 **Stage 5 (리팩토링 개발)**에서:
|
||||||
|
|
||||||
|
1. 위 순서대로 파일 수정
|
||||||
|
2. 각 변경 후 기존 예제 실행 확인
|
||||||
|
3. 변경된 부분에 대한 주석/문서 업데이트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 설계는 `/design` 에이전트에 의해 자동 생성되었습니다.*
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
# Instagram Graph API POC
|
||||||
|
|
||||||
|
Instagram Graph API를 활용한 비즈니스/크리에이터 계정 관리 POC입니다.
|
||||||
|
|
||||||
|
## 기능
|
||||||
|
|
||||||
|
- **인증**: 토큰 검증, 장기 토큰 교환, 토큰 갱신
|
||||||
|
- **계정**: 프로필 정보 조회
|
||||||
|
- **미디어**: 목록/상세 조회, 이미지/비디오/캐러셀 게시
|
||||||
|
- **인사이트**: 계정/미디어 성과 지표 조회
|
||||||
|
- **댓글**: 댓글 조회, 답글 작성
|
||||||
|
|
||||||
|
## 요구사항
|
||||||
|
|
||||||
|
### 1. Instagram 비즈니스/크리에이터 계정
|
||||||
|
|
||||||
|
개인 계정은 지원하지 않습니다. Instagram 앱에서 비즈니스 또는 크리에이터 계정으로 전환해야 합니다.
|
||||||
|
|
||||||
|
### 2. Facebook 앱 설정
|
||||||
|
|
||||||
|
1. [Meta for Developers](https://developers.facebook.com/)에서 앱 생성
|
||||||
|
2. Instagram Graph API 제품 추가
|
||||||
|
3. 필요한 권한 설정:
|
||||||
|
- `instagram_basic` - 기본 프로필 정보
|
||||||
|
- `instagram_content_publish` - 콘텐츠 게시
|
||||||
|
- `instagram_manage_comments` - 댓글 관리
|
||||||
|
- `instagram_manage_insights` - 인사이트 조회
|
||||||
|
|
||||||
|
### 3. 액세스 토큰 발급
|
||||||
|
|
||||||
|
Graph API Explorer 또는 OAuth 흐름을 통해 액세스 토큰을 발급받습니다.
|
||||||
|
|
||||||
|
## 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 프로젝트 루트에서
|
||||||
|
uv add httpx pydantic-settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## 환경변수 설정
|
||||||
|
|
||||||
|
`.env` 파일 또는 환경변수로 설정:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 필수
|
||||||
|
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
|
||||||
|
|
||||||
|
# 토큰 검증/교환 시 필요
|
||||||
|
export INSTAGRAM_APP_ID="your_app_id"
|
||||||
|
export INSTAGRAM_APP_SECRET="your_app_secret"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
### 기본 사용
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from poc.instagram import InstagramGraphClient
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
# 계정 정보 조회
|
||||||
|
account = await client.get_account()
|
||||||
|
print(f"@{account.username}: {account.followers_count} followers")
|
||||||
|
|
||||||
|
# 미디어 목록 조회
|
||||||
|
media_list = await client.get_media_list(limit=10)
|
||||||
|
for media in media_list.data:
|
||||||
|
print(f"{media.media_type}: {media.like_count} likes")
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
### 토큰 검증
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
token_info = await client.debug_token()
|
||||||
|
print(f"Valid: {token_info.data.is_valid}")
|
||||||
|
print(f"Expires: {token_info.data.expires_at_datetime}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이미지 게시
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
media = await client.publish_image(
|
||||||
|
image_url="https://example.com/image.jpg",
|
||||||
|
caption="My photo! #photography",
|
||||||
|
)
|
||||||
|
print(f"Posted: {media.permalink}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 인사이트 조회
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
# 계정 인사이트
|
||||||
|
insights = await client.get_account_insights(
|
||||||
|
metrics=["impressions", "reach"],
|
||||||
|
period="day",
|
||||||
|
)
|
||||||
|
for insight in insights.data:
|
||||||
|
print(f"{insight.name}: {insight.latest_value}")
|
||||||
|
|
||||||
|
# 미디어 인사이트
|
||||||
|
media_insights = await client.get_media_insights("MEDIA_ID")
|
||||||
|
reach = media_insights.get_metric("reach")
|
||||||
|
print(f"Reach: {reach.latest_value}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 댓글 관리
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
# 댓글 조회
|
||||||
|
comments = await client.get_comments("MEDIA_ID")
|
||||||
|
for comment in comments.data:
|
||||||
|
print(f"@{comment.username}: {comment.text}")
|
||||||
|
|
||||||
|
# 답글 작성
|
||||||
|
reply = await client.reply_comment(
|
||||||
|
comment_id="COMMENT_ID",
|
||||||
|
message="Thanks!",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 예제 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 인증 예제
|
||||||
|
python -m poc.instagram.examples.auth_example
|
||||||
|
|
||||||
|
# 계정 예제
|
||||||
|
python -m poc.instagram.examples.account_example
|
||||||
|
|
||||||
|
# 미디어 예제
|
||||||
|
python -m poc.instagram.examples.media_example
|
||||||
|
|
||||||
|
# 인사이트 예제
|
||||||
|
python -m poc.instagram.examples.insights_example
|
||||||
|
|
||||||
|
# 댓글 예제
|
||||||
|
python -m poc.instagram.examples.comments_example
|
||||||
|
```
|
||||||
|
|
||||||
|
## 에러 처리
|
||||||
|
|
||||||
|
```python
|
||||||
|
from poc.instagram import (
|
||||||
|
InstagramGraphClient,
|
||||||
|
AuthenticationError,
|
||||||
|
RateLimitError,
|
||||||
|
PermissionError,
|
||||||
|
MediaPublishError,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
try:
|
||||||
|
account = await client.get_account()
|
||||||
|
except AuthenticationError as e:
|
||||||
|
print(f"토큰 오류: {e}")
|
||||||
|
except RateLimitError as e:
|
||||||
|
print(f"Rate limit 초과. {e.retry_after}초 후 재시도")
|
||||||
|
except PermissionError as e:
|
||||||
|
print(f"권한 부족: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limit
|
||||||
|
|
||||||
|
- 시간당 200회 요청 제한 (사용자 토큰당)
|
||||||
|
- 429 응답 시 자동으로 지수 백오프 재시도
|
||||||
|
- `RateLimitError.retry_after`로 대기 시간 확인 가능
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
poc/instagram/
|
||||||
|
├── __init__.py # 패키지 진입점
|
||||||
|
├── config.py # 설정 (환경변수)
|
||||||
|
├── exceptions.py # 커스텀 예외
|
||||||
|
├── models.py # Pydantic 모델
|
||||||
|
├── client.py # API 클라이언트
|
||||||
|
├── examples/ # 실행 예제
|
||||||
|
│ ├── auth_example.py
|
||||||
|
│ ├── account_example.py
|
||||||
|
│ ├── media_example.py
|
||||||
|
│ ├── insights_example.py
|
||||||
|
│ └── comments_example.py
|
||||||
|
├── DESIGN.md # 설계 문서
|
||||||
|
└── README.md # 사용 가이드 (본 문서)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 참고 문서
|
||||||
|
|
||||||
|
- [Instagram Graph API 공식 문서](https://developers.facebook.com/docs/instagram-api)
|
||||||
|
- [Instagram Platform API 가이드](https://developers.facebook.com/docs/instagram-platform)
|
||||||
|
- [Graph API Explorer](https://developers.facebook.com/tools/explorer/)
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
# Instagram Graph API POC - 최종 코드 리뷰 리포트
|
||||||
|
|
||||||
|
**리뷰 일시**: 2026-01-29
|
||||||
|
**리뷰 대상**: `poc/instagram/` 리팩토링 완료 코드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 리뷰 요약
|
||||||
|
|
||||||
|
### V1 리뷰에서 지적된 이슈 해결 현황
|
||||||
|
|
||||||
|
| 심각도 | 이슈 | 상태 | 비고 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| 🔴 Critical | `PermissionError` 내장 예외 섀도잉 | ✅ 해결 | `InstagramPermissionError`로 변경 |
|
||||||
|
| 🔴 Critical | 토큰 노출 위험 | ✅ 해결 | `_mask_sensitive_params()` 추가 |
|
||||||
|
| 🔴 Critical | 싱글톤 설정 테스트 어려움 | ✅ 해결 | `get_settings()` 팩토리 패턴 |
|
||||||
|
| 🟡 Warning | 시간 계산 정확도 | ✅ 해결 | `time.monotonic()` 사용 |
|
||||||
|
| 🟡 Warning | 타임존 미처리 | ✅ 해결 | `timezone.utc` 명시 |
|
||||||
|
| 🟡 Warning | 캐러셀 순차 처리 | ✅ 해결 | `asyncio.gather()` 병렬화 |
|
||||||
|
| 🟡 Warning | 서브코드 미활용 | ✅ 해결 | `ERROR_CODE_SUBCODE_MAPPING` 추가 |
|
||||||
|
| 🟡 Warning | JSON 파싱 에러 | ✅ 해결 | try-except 안전 처리 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 개선 상세
|
||||||
|
|
||||||
|
### 2.1 예외 클래스 이름 변경 ✅
|
||||||
|
|
||||||
|
**변경 전** ([exceptions.py:105](poc/instagram/exceptions.py#L105)):
|
||||||
|
```python
|
||||||
|
class PermissionError(InstagramAPIError): ... # Python 내장 예외와 충돌
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
```python
|
||||||
|
class InstagramPermissionError(InstagramAPIError):
|
||||||
|
"""Python 내장 PermissionError와 구분하기 위해 접두사 사용"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 하위 호환성 alias
|
||||||
|
PermissionError = InstagramPermissionError
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 토큰 마스킹 ✅
|
||||||
|
|
||||||
|
**추가** ([client.py:120-141](poc/instagram/client.py#L120-L141)):
|
||||||
|
```python
|
||||||
|
def _mask_sensitive_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
SENSITIVE_KEYS = {"access_token", "client_secret", "input_token"}
|
||||||
|
masked = params.copy()
|
||||||
|
for key in SENSITIVE_KEYS:
|
||||||
|
if key in masked and masked[key]:
|
||||||
|
value = str(masked[key])
|
||||||
|
if len(value) > 14:
|
||||||
|
masked[key] = f"{value[:10]}...{value[-4:]}"
|
||||||
|
else:
|
||||||
|
masked[key] = "***"
|
||||||
|
return masked
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 설정 팩토리 패턴 ✅
|
||||||
|
|
||||||
|
**추가** ([config.py:121-140](poc/instagram/config.py#L121-L140)):
|
||||||
|
```python
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> InstagramSettings:
|
||||||
|
"""테스트 시 cache_clear()로 초기화 가능"""
|
||||||
|
return InstagramSettings()
|
||||||
|
|
||||||
|
# 하위 호환성 유지
|
||||||
|
settings = get_settings()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 시간 계산 정확도 ✅
|
||||||
|
|
||||||
|
**변경** ([client.py:296](poc/instagram/client.py#L296)):
|
||||||
|
```python
|
||||||
|
# 변경 전: elapsed += poll_interval
|
||||||
|
# 변경 후:
|
||||||
|
start_time = time.monotonic()
|
||||||
|
while True:
|
||||||
|
elapsed = time.monotonic() - start_time
|
||||||
|
if elapsed >= timeout:
|
||||||
|
break
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 타임존 처리 ✅
|
||||||
|
|
||||||
|
**변경** ([models.py:107-114](poc/instagram/models.py#L107-L114)):
|
||||||
|
```python
|
||||||
|
@property
|
||||||
|
def expires_at_datetime(self) -> datetime:
|
||||||
|
"""만료 시각을 UTC datetime으로 변환"""
|
||||||
|
return datetime.fromtimestamp(self.expires_at, tz=timezone.utc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""토큰 만료 여부 확인 (UTC 기준)"""
|
||||||
|
return datetime.now(timezone.utc).timestamp() > self.expires_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 캐러셀 병렬 처리 ✅
|
||||||
|
|
||||||
|
**변경** ([client.py:791-814](poc/instagram/client.py#L791-L814)):
|
||||||
|
```python
|
||||||
|
async def create_item_container(url: str, index: int) -> str:
|
||||||
|
# ...
|
||||||
|
return response["id"]
|
||||||
|
|
||||||
|
# 병렬로 모든 컨테이너 생성
|
||||||
|
children_ids = await asyncio.gather(
|
||||||
|
*[create_item_container(url, i) for i, url in enumerate(media_urls)]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 서브코드 매핑 ✅
|
||||||
|
|
||||||
|
**추가** ([exceptions.py:205-211](poc/instagram/exceptions.py#L205-L211)):
|
||||||
|
```python
|
||||||
|
ERROR_CODE_SUBCODE_MAPPING: dict[tuple[int, int], type[InstagramAPIError]] = {
|
||||||
|
(100, 33): ResourceNotFoundError,
|
||||||
|
(190, 458): AuthenticationError,
|
||||||
|
(190, 463): AuthenticationError,
|
||||||
|
(190, 467): AuthenticationError,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.8 JSON 파싱 안전 처리 ✅
|
||||||
|
|
||||||
|
**변경** ([client.py:227-238](poc/instagram/client.py#L227-L238)):
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
response_data = response.json()
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[_request] JSON 파싱 실패: status={response.status_code}, "
|
||||||
|
f"body={response.text[:200]}"
|
||||||
|
)
|
||||||
|
raise InstagramAPIError(f"API 응답 파싱 실패: {e}", code=response.status_code) from e
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 최종 코드 품질 평가
|
||||||
|
|
||||||
|
| 항목 | V1 점수 | V2 점수 | 개선 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| **코드 품질** | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐⭐ | +1 |
|
||||||
|
| **보안** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | +1 |
|
||||||
|
| **성능** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | +1 |
|
||||||
|
| **가독성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 유지 |
|
||||||
|
| **확장성** | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐⭐ | +1 |
|
||||||
|
| **테스트 용이성** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | +1 |
|
||||||
|
|
||||||
|
**종합 점수: 4.5 / 5.0** (V1: 3.7 → V2: 4.5, +0.8 향상)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 남은 개선 사항 (Minor)
|
||||||
|
|
||||||
|
아래 항목들은 필수는 아니지만 추후 개선을 고려할 수 있습니다:
|
||||||
|
|
||||||
|
| 항목 | 설명 | 우선순위 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 예제 공통 모듈화 | 각 예제의 중복 로깅 설정 분리 | 낮음 |
|
||||||
|
| API 상수 분리 | `limit` 최대값 등 상수 별도 관리 | 낮음 |
|
||||||
|
| 모델 예제 일관성 | 모든 모델에 `json_schema_extra` 추가 | 낮음 |
|
||||||
|
| 비동기 폴링 개선 | 지수 백오프 패턴 적용 | 중간 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 파일 구조 최종
|
||||||
|
|
||||||
|
```
|
||||||
|
poc/instagram/
|
||||||
|
├── __init__.py # 패키지 exports (업데이트됨)
|
||||||
|
├── config.py # 설정 + get_settings() 팩토리 (업데이트됨)
|
||||||
|
├── exceptions.py # InstagramPermissionError + 서브코드 매핑 (업데이트됨)
|
||||||
|
├── models.py # 타임존 처리 추가 (업데이트됨)
|
||||||
|
├── client.py # 토큰 마스킹, 병렬 처리, 시간 정확도 (업데이트됨)
|
||||||
|
├── DESIGN.md # 초기 설계 문서
|
||||||
|
├── DESIGN_V2.md # 리팩토링 설계 문서
|
||||||
|
├── REVIEW_V1.md # 초기 리뷰 리포트
|
||||||
|
├── REVIEW_FINAL.md # 최종 리뷰 리포트 (현재 파일)
|
||||||
|
├── README.md # 사용 가이드
|
||||||
|
└── examples/
|
||||||
|
├── __init__.py
|
||||||
|
├── auth_example.py
|
||||||
|
├── account_example.py
|
||||||
|
├── media_example.py
|
||||||
|
├── insights_example.py
|
||||||
|
└── comments_example.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 결론
|
||||||
|
|
||||||
|
### 성과
|
||||||
|
|
||||||
|
1. **모든 Critical 이슈 해결**: 내장 예외 충돌, 보안 위험, 테스트 용이성 문제 모두 해결
|
||||||
|
2. **모든 Major 이슈 해결**: 시간 처리, 타임존, 병렬 처리, 에러 처리 개선
|
||||||
|
3. **하위 호환성 유지**: alias와 deprecated 표시로 기존 코드 호환
|
||||||
|
4. **코드 품질 대폭 향상**: 종합 점수 3.7 → 4.5
|
||||||
|
|
||||||
|
### POC 완성도
|
||||||
|
|
||||||
|
Instagram Graph API POC가 프로덕션 수준의 코드 품질을 갖추게 되었습니다:
|
||||||
|
|
||||||
|
- ✅ 완전한 타입 힌트
|
||||||
|
- ✅ 상세한 docstring과 예제
|
||||||
|
- ✅ 계층화된 예외 처리
|
||||||
|
- ✅ Rate Limit 재시도 로직
|
||||||
|
- ✅ 비동기 컨텍스트 매니저
|
||||||
|
- ✅ Container 기반 미디어 게시
|
||||||
|
- ✅ 테스트 가능한 설계
|
||||||
|
- ✅ 보안 고려 (토큰 마스킹)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 리뷰는 `/review` 에이전트에 의해 자동 생성되었습니다.*
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
# Instagram Graph API POC - 코드 리뷰 리포트 (V1)
|
||||||
|
|
||||||
|
**리뷰 일시**: 2026-01-29
|
||||||
|
**리뷰 대상**: `poc/instagram/` 초기 구현
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 리뷰 대상 파일
|
||||||
|
|
||||||
|
| 파일 | 라인 수 | 설명 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `config.py` | 123 | 설정 관리 (pydantic-settings) |
|
||||||
|
| `exceptions.py` | 230 | 커스텀 예외 클래스 |
|
||||||
|
| `models.py` | 498 | Pydantic v2 데이터 모델 |
|
||||||
|
| `client.py` | 1000 | 비동기 API 클라이언트 |
|
||||||
|
| `__init__.py` | 65 | 패키지 익스포트 |
|
||||||
|
| `examples/*.py` | 5개 | 실행 가능한 예제 |
|
||||||
|
| `README.md` | - | 사용 가이드 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 흐름 분석
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자 코드
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
InstagramGraphClient (context manager)
|
||||||
|
│
|
||||||
|
├── __aenter__: httpx.AsyncClient 초기화
|
||||||
|
│
|
||||||
|
├── API 메서드 호출
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ _request() ─────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
│ ├── 토큰 자동 추가 │
|
||||||
|
│ ├── 재시도 로직 │◄── Rate Limit (429)
|
||||||
|
│ ├── 에러 응답 파싱 │◄── Server Error (5xx)
|
||||||
|
│ └── 예외 변환 │
|
||||||
|
│ │
|
||||||
|
│ ◄───────────────────────────────┘
|
||||||
|
│
|
||||||
|
└── __aexit__: 클라이언트 정리
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 검사 결과
|
||||||
|
|
||||||
|
### 🔴 Critical (즉시 수정 필요)
|
||||||
|
|
||||||
|
| 파일:라인 | 문제 | 설명 | 개선 방안 |
|
||||||
|
|-----------|------|------|----------|
|
||||||
|
| `exceptions.py:105` | 내장 예외 섀도잉 | `PermissionError`가 Python 내장 `PermissionError`를 섀도잉함 | `InstagramPermissionError`로 이름 변경 권장 |
|
||||||
|
| `client.py:152` | 토큰 노출 위험 | 디버그 로그에 요청 URL/파라미터가 기록될 수 있어 토큰 노출 가능성 | 민감 정보 마스킹 로직 추가 |
|
||||||
|
| `config.py:121-122` | 싱글톤 초기화 시점 | 모듈 로드 시 즉시 Settings 인스턴스 생성으로 테스트 시 환경변수 모킹 어려움 | lazy initialization 또는 팩토리 함수 패턴 적용 |
|
||||||
|
|
||||||
|
### 🟡 Warning (권장 수정)
|
||||||
|
|
||||||
|
| 파일:라인 | 문제 | 설명 | 개선 방안 |
|
||||||
|
|-----------|------|------|----------|
|
||||||
|
| `client.py:269-293` | 시간 계산 정확도 | `elapsed += poll_interval`은 실제 sleep 시간과 다를 수 있음 | `time.monotonic()` 기반 계산으로 변경 |
|
||||||
|
| `models.py:107-114` | 타임존 미처리 | `datetime.now()`가 naive datetime을 반환, `expires_at`은 UTC일 수 있음 | `datetime.now(timezone.utc)` 사용 |
|
||||||
|
| `client.py:756-768` | 순차 컨테이너 생성 | 캐러셀 이미지 컨테이너를 순차적으로 생성하여 느림 | `asyncio.gather()`로 병렬 처리 |
|
||||||
|
| `client.py:84-88` | 생성자 예외 | `__init__`에서 예외 발생 시 비동기 리소스 정리 불가 | validate 메서드 분리 또는 팩토리 패턴 |
|
||||||
|
| `exceptions.py:188-198` | 서브코드 미활용 | ERROR_CODE_MAPPING이 subcode를 고려하지 않음 | (code, subcode) 튜플 기반 매핑 확장 |
|
||||||
|
| `client.py:204` | 무조건 JSON 파싱 | 비정상 응답 시 JSON 파싱 에러 발생 가능 | try-except로 감싸고 원본 응답 포함 |
|
||||||
|
|
||||||
|
### 🟢 Info (참고 사항)
|
||||||
|
|
||||||
|
| 파일:라인 | 내용 |
|
||||||
|
|-----------|------|
|
||||||
|
| `models.py:256-269` | `json_schema_extra` example이 `Media` 모델에만 있음, 일관성 위해 다른 모델에도 추가 고려 |
|
||||||
|
| `client.py:519-525` | `limit` 파라미터 최대값(100) 상수화 권장 |
|
||||||
|
| `config.py:59` | API 버전 `v21.0` 하드코딩, 환경변수 오버라이드 가능하지만 업데이트 정책 문서화 필요 |
|
||||||
|
| `examples/*.py` | 각 예제에서 중복되는 로깅 설정 코드가 있음, 공통 모듈로 분리 가능 |
|
||||||
|
| `__init__.py` | `__all__` 정의 완전하고 명확함 ✓ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 성능 분석
|
||||||
|
|
||||||
|
### 잠재적 성능 이슈
|
||||||
|
|
||||||
|
1. **캐러셀 게시 병목** (`client.py:756-768`)
|
||||||
|
- 현재: 이미지별 순차 컨테이너 생성 (N개 이미지 = N번 API 호출 직렬)
|
||||||
|
- 영향: 10개 이미지 → 최소 10 * RTT 시간
|
||||||
|
- 개선: `asyncio.gather()`로 병렬 처리
|
||||||
|
|
||||||
|
2. **컨테이너 폴링 비효율** (`client.py:239-297`)
|
||||||
|
- 현재: 고정 간격(2초) 폴링
|
||||||
|
- 개선: 지수 백오프 + 최대 간격 패턴
|
||||||
|
|
||||||
|
3. **계정 ID 캐싱** (`client.py:463-486`)
|
||||||
|
- 현재: 인스턴스 레벨 캐싱 ✓ (잘 구현됨)
|
||||||
|
- 참고: 멀티 계정 지원 시 캐시 키 확장 필요
|
||||||
|
|
||||||
|
### 추천 최적화
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 병렬 컨테이너 생성 예시
|
||||||
|
async def _create_carousel_containers(self, media_urls: list[str]) -> list[str]:
|
||||||
|
tasks = [
|
||||||
|
self._create_container(url, is_carousel_item=True)
|
||||||
|
for url in media_urls
|
||||||
|
]
|
||||||
|
return await asyncio.gather(*tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 보안 분석
|
||||||
|
|
||||||
|
### 확인된 보안 사항
|
||||||
|
|
||||||
|
| 항목 | 상태 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| Credentials 하드코딩 | ✅ 양호 | 환경변수/설정 파일에서 로드 |
|
||||||
|
| HTTPS 사용 | ✅ 양호 | 모든 API URL이 HTTPS |
|
||||||
|
| 토큰 전송 | ✅ 양호 | 쿼리 파라미터로 전송 (Graph API 표준) |
|
||||||
|
| 에러 메시지 노출 | ⚠️ 주의 | `fbtrace_id` 등 디버그 정보 로깅 |
|
||||||
|
| 로그 내 토큰 | 🔴 위험 | 요청 파라미터 로깅 시 토큰 노출 가능 |
|
||||||
|
|
||||||
|
### 보안 개선 권장
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 민감 정보 마스킹 예시
|
||||||
|
def _mask_token(self, params: dict) -> dict:
|
||||||
|
"""로깅용 파라미터에서 토큰 마스킹"""
|
||||||
|
masked = params.copy()
|
||||||
|
if "access_token" in masked:
|
||||||
|
token = masked["access_token"]
|
||||||
|
masked["access_token"] = f"{token[:10]}...{token[-4:]}"
|
||||||
|
return masked
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 전체 평가
|
||||||
|
|
||||||
|
| 항목 | 점수 | 평가 |
|
||||||
|
|------|------|------|
|
||||||
|
| **코드 품질** | ⭐⭐⭐⭐☆ | 타입 힌트 완전, docstring 충실, 구조화 양호 |
|
||||||
|
| **보안** | ⭐⭐⭐☆☆ | 기본 보안 준수, 로깅 관련 개선 필요 |
|
||||||
|
| **성능** | ⭐⭐⭐☆☆ | 기본 재시도 로직 있음, 병렬처리 개선 여지 |
|
||||||
|
| **가독성** | ⭐⭐⭐⭐⭐ | 명확한 네이밍, 일관된 구조, 주석 충실 |
|
||||||
|
| **확장성** | ⭐⭐⭐⭐☆ | 모듈화 잘 됨, 설정 분리 완료 |
|
||||||
|
| **테스트 용이성** | ⭐⭐⭐☆☆ | 싱글톤 설정으로 모킹 어려움 |
|
||||||
|
|
||||||
|
**종합 점수: 3.7 / 5.0**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 요약
|
||||||
|
|
||||||
|
### 잘 된 점
|
||||||
|
|
||||||
|
1. **계층화된 예외 처리**: API 에러 코드별 명확한 예외 분류
|
||||||
|
2. **비동기 컨텍스트 매니저**: 리소스 관리가 깔끔함
|
||||||
|
3. **완전한 타입 힌트**: 모든 함수/메서드에 타입 명시
|
||||||
|
4. **상세한 docstring**: 사용 예제까지 포함
|
||||||
|
5. **재시도 로직**: Rate Limit 및 서버 에러 처리
|
||||||
|
6. **Container 기반 게시**: Instagram API 표준 준수
|
||||||
|
|
||||||
|
### 개선 필요 항목
|
||||||
|
|
||||||
|
1. **Critical**
|
||||||
|
- `PermissionError` 이름 충돌 해결
|
||||||
|
- 로그에서 토큰 마스킹
|
||||||
|
- 설정 싱글톤 패턴 개선
|
||||||
|
|
||||||
|
2. **Major**
|
||||||
|
- 타임존 처리 추가
|
||||||
|
- 캐러셀 병렬 처리
|
||||||
|
- JSON 파싱 에러 처리
|
||||||
|
- 시간 계산 정확도 개선
|
||||||
|
|
||||||
|
3. **Minor**
|
||||||
|
- 예제 코드 공통 모듈화
|
||||||
|
- API 상수 분리
|
||||||
|
- 모델 예제 일관성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 다음 단계
|
||||||
|
|
||||||
|
이 리뷰 결과를 바탕으로 **Stage 4 (설계 리팩토링)**에서:
|
||||||
|
|
||||||
|
1. `PermissionError` → `InstagramPermissionError` 이름 변경 설계
|
||||||
|
2. 로깅 시 민감 정보 마스킹 전략 수립
|
||||||
|
3. 설정 팩토리 패턴 설계
|
||||||
|
4. 타임존 처리 정책 정의
|
||||||
|
5. 병렬 처리 아키텍처 설계
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 리뷰는 `/review` 에이전트에 의해 자동 생성되었습니다.*
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API POC 패키지
|
||||||
|
|
||||||
|
Instagram Graph API와의 통신을 위한 비동기 클라이언트를 제공합니다.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from poc.instagram import InstagramGraphClient
|
||||||
|
|
||||||
|
async with InstagramGraphClient(access_token="YOUR_TOKEN") as client:
|
||||||
|
# 계정 정보 조회
|
||||||
|
account = await client.get_account()
|
||||||
|
print(f"@{account.username}")
|
||||||
|
|
||||||
|
# 미디어 목록 조회
|
||||||
|
media_list = await client.get_media_list(limit=10)
|
||||||
|
for media in media_list.data:
|
||||||
|
print(f"{media.media_type}: {media.caption}")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .client import InstagramGraphClient
|
||||||
|
from .config import InstagramSettings, get_settings, settings
|
||||||
|
from .exceptions import (
|
||||||
|
AuthenticationError,
|
||||||
|
ContainerStatusError,
|
||||||
|
ContainerTimeoutError,
|
||||||
|
InstagramAPIError,
|
||||||
|
InstagramPermissionError,
|
||||||
|
InvalidRequestError,
|
||||||
|
MediaPublishError,
|
||||||
|
PermissionError, # deprecated alias, use InstagramPermissionError
|
||||||
|
RateLimitError,
|
||||||
|
ResourceNotFoundError,
|
||||||
|
)
|
||||||
|
from .models import (
|
||||||
|
Account,
|
||||||
|
AccountType,
|
||||||
|
APIError,
|
||||||
|
Comment,
|
||||||
|
CommentList,
|
||||||
|
ContainerStatus,
|
||||||
|
ErrorResponse,
|
||||||
|
Insight,
|
||||||
|
InsightResponse,
|
||||||
|
InsightValue,
|
||||||
|
Media,
|
||||||
|
MediaContainer,
|
||||||
|
MediaList,
|
||||||
|
MediaType,
|
||||||
|
Paging,
|
||||||
|
TokenDebugData,
|
||||||
|
TokenDebugResponse,
|
||||||
|
TokenInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Client
|
||||||
|
"InstagramGraphClient",
|
||||||
|
# Config
|
||||||
|
"InstagramSettings",
|
||||||
|
"get_settings",
|
||||||
|
"settings",
|
||||||
|
# Exceptions
|
||||||
|
"InstagramAPIError",
|
||||||
|
"AuthenticationError",
|
||||||
|
"RateLimitError",
|
||||||
|
"InstagramPermissionError",
|
||||||
|
"PermissionError", # deprecated alias
|
||||||
|
"MediaPublishError",
|
||||||
|
"InvalidRequestError",
|
||||||
|
"ResourceNotFoundError",
|
||||||
|
"ContainerStatusError",
|
||||||
|
"ContainerTimeoutError",
|
||||||
|
# Models - Auth
|
||||||
|
"TokenInfo",
|
||||||
|
"TokenDebugData",
|
||||||
|
"TokenDebugResponse",
|
||||||
|
# Models - Account
|
||||||
|
"Account",
|
||||||
|
"AccountType",
|
||||||
|
# Models - Media
|
||||||
|
"Media",
|
||||||
|
"MediaType",
|
||||||
|
"MediaContainer",
|
||||||
|
"ContainerStatus",
|
||||||
|
"MediaList",
|
||||||
|
# Models - Insight
|
||||||
|
"Insight",
|
||||||
|
"InsightValue",
|
||||||
|
"InsightResponse",
|
||||||
|
# Models - Comment
|
||||||
|
"Comment",
|
||||||
|
"CommentList",
|
||||||
|
# Models - Common
|
||||||
|
"Paging",
|
||||||
|
"APIError",
|
||||||
|
"ErrorResponse",
|
||||||
|
]
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API 설정 모듈
|
||||||
|
|
||||||
|
환경변수를 통해 Instagram API 연동에 필요한 설정을 관리합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramSettings(BaseSettings):
|
||||||
|
"""
|
||||||
|
Instagram Graph API 설정
|
||||||
|
|
||||||
|
환경변수 또는 .env 파일에서 설정을 로드합니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
app_id: Facebook/Instagram 앱 ID
|
||||||
|
app_secret: Facebook/Instagram 앱 시크릿
|
||||||
|
access_token: Instagram 액세스 토큰
|
||||||
|
api_version: Graph API 버전 (기본: v21.0)
|
||||||
|
base_url: Instagram Graph API 기본 URL
|
||||||
|
facebook_base_url: Facebook Graph API 기본 URL (토큰 디버그용)
|
||||||
|
timeout: HTTP 요청 타임아웃 (초)
|
||||||
|
max_retries: 최대 재시도 횟수
|
||||||
|
retry_base_delay: 재시도 기본 대기 시간 (초)
|
||||||
|
retry_max_delay: 재시도 최대 대기 시간 (초)
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="INSTAGRAM_",
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 필수 설정 (환경변수에서 로드)
|
||||||
|
# ==========================================================================
|
||||||
|
app_id: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Facebook/Instagram 앱 ID",
|
||||||
|
)
|
||||||
|
app_secret: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Facebook/Instagram 앱 시크릿",
|
||||||
|
)
|
||||||
|
access_token: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Instagram 액세스 토큰",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# API 설정
|
||||||
|
# ==========================================================================
|
||||||
|
api_version: str = Field(
|
||||||
|
default="v21.0",
|
||||||
|
description="Graph API 버전",
|
||||||
|
)
|
||||||
|
base_url: str = Field(
|
||||||
|
default="https://graph.instagram.com",
|
||||||
|
description="Instagram Graph API 기본 URL",
|
||||||
|
)
|
||||||
|
facebook_base_url: str = Field(
|
||||||
|
default="https://graph.facebook.com",
|
||||||
|
description="Facebook Graph API 기본 URL (토큰 디버그용)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# HTTP 클라이언트 설정
|
||||||
|
# ==========================================================================
|
||||||
|
timeout: float = Field(
|
||||||
|
default=30.0,
|
||||||
|
description="HTTP 요청 타임아웃 (초)",
|
||||||
|
)
|
||||||
|
max_retries: int = Field(
|
||||||
|
default=3,
|
||||||
|
description="최대 재시도 횟수",
|
||||||
|
)
|
||||||
|
retry_base_delay: float = Field(
|
||||||
|
default=1.0,
|
||||||
|
description="재시도 기본 대기 시간 (초)",
|
||||||
|
)
|
||||||
|
retry_max_delay: float = Field(
|
||||||
|
default=60.0,
|
||||||
|
description="재시도 최대 대기 시간 (초)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 미디어 게시 설정
|
||||||
|
# ==========================================================================
|
||||||
|
container_poll_interval: float = Field(
|
||||||
|
default=2.0,
|
||||||
|
description="컨테이너 상태 확인 간격 (초)",
|
||||||
|
)
|
||||||
|
container_timeout: float = Field(
|
||||||
|
default=60.0,
|
||||||
|
description="컨테이너 상태 확인 타임아웃 (초)",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_access_token(self) -> str:
|
||||||
|
"""앱 액세스 토큰 (토큰 디버그용)"""
|
||||||
|
if not self.app_id or not self.app_secret:
|
||||||
|
raise ValueError("app_id와 app_secret이 설정되어야 합니다.")
|
||||||
|
return f"{self.app_id}|{self.app_secret}"
|
||||||
|
|
||||||
|
def get_instagram_url(self, endpoint: str) -> str:
|
||||||
|
"""Instagram Graph API 전체 URL 생성"""
|
||||||
|
endpoint = endpoint.lstrip("/")
|
||||||
|
return f"{self.base_url}/{endpoint}"
|
||||||
|
|
||||||
|
def get_facebook_url(self, endpoint: str) -> str:
|
||||||
|
"""Facebook Graph API 전체 URL 생성 (버전 포함)"""
|
||||||
|
endpoint = endpoint.lstrip("/")
|
||||||
|
return f"{self.facebook_base_url}/{self.api_version}/{endpoint}"
|
||||||
|
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> InstagramSettings:
|
||||||
|
"""
|
||||||
|
설정 인스턴스 반환 (캐싱됨)
|
||||||
|
|
||||||
|
테스트 시 캐시 초기화:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InstagramSettings 인스턴스
|
||||||
|
"""
|
||||||
|
return InstagramSettings()
|
||||||
|
|
||||||
|
|
||||||
|
# 하위 호환성을 위한 기본 인스턴스
|
||||||
|
# @deprecated: get_settings() 사용 권장
|
||||||
|
settings = get_settings()
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API 예제 모듈
|
||||||
|
|
||||||
|
각 기능별 실행 가능한 예제를 제공합니다.
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API 계정 정보 조회 예제
|
||||||
|
|
||||||
|
비즈니스/크리에이터 계정의 프로필 정보를 조회합니다.
|
||||||
|
|
||||||
|
실행 방법:
|
||||||
|
```bash
|
||||||
|
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
|
||||||
|
python -m poc.instagram.examples.account_example
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def example_get_account():
|
||||||
|
"""계정 정보 조회 예제"""
|
||||||
|
from poc.instagram import InstagramGraphClient, AuthenticationError
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("계정 정보 조회")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
account = await client.get_account()
|
||||||
|
|
||||||
|
print(f"\n📱 계정 정보")
|
||||||
|
print("-" * 40)
|
||||||
|
print(f"🆔 ID: {account.id}")
|
||||||
|
print(f"👤 사용자명: @{account.username}")
|
||||||
|
print(f"📛 이름: {account.name or '(없음)'}")
|
||||||
|
print(f"📊 계정 타입: {account.account_type}")
|
||||||
|
print(f"🖼️ 프로필 사진: {account.profile_picture_url or '(없음)'}")
|
||||||
|
|
||||||
|
print(f"\n📈 통계")
|
||||||
|
print("-" * 40)
|
||||||
|
print(f"👥 팔로워: {account.followers_count:,}명")
|
||||||
|
print(f"👤 팔로잉: {account.follows_count:,}명")
|
||||||
|
print(f"📷 게시물: {account.media_count:,}개")
|
||||||
|
|
||||||
|
if account.biography:
|
||||||
|
print(f"\n📝 자기소개")
|
||||||
|
print("-" * 40)
|
||||||
|
print(f"{account.biography}")
|
||||||
|
|
||||||
|
if account.website:
|
||||||
|
print(f"\n🌐 웹사이트: {account.website}")
|
||||||
|
|
||||||
|
except AuthenticationError as e:
|
||||||
|
print(f"❌ 인증 에러: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_get_account_id():
|
||||||
|
"""계정 ID만 조회 예제"""
|
||||||
|
from poc.instagram import InstagramGraphClient
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("계정 ID 조회 (캐시 테스트)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
# 첫 번째 호출 (API 요청)
|
||||||
|
print("\n1️⃣ 첫 번째 조회 (API 호출)")
|
||||||
|
account_id = await client.get_account_id()
|
||||||
|
print(f" 계정 ID: {account_id}")
|
||||||
|
|
||||||
|
# 두 번째 호출 (캐시 사용)
|
||||||
|
print("\n2️⃣ 두 번째 조회 (캐시)")
|
||||||
|
account_id_cached = await client.get_account_id()
|
||||||
|
print(f" 계정 ID: {account_id_cached}")
|
||||||
|
|
||||||
|
print("\n✅ 캐시 동작 확인 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""모든 계정 예제 실행"""
|
||||||
|
print("\n👤 Instagram Graph API 계정 예제")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 계정 정보 조회
|
||||||
|
await example_get_account()
|
||||||
|
|
||||||
|
# 계정 ID 캐시 테스트
|
||||||
|
await example_get_account_id()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✅ 예제 완료")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API 인증 예제
|
||||||
|
|
||||||
|
토큰 검증, 교환, 갱신 기능을 테스트합니다.
|
||||||
|
|
||||||
|
실행 방법:
|
||||||
|
```bash
|
||||||
|
# 환경변수 설정
|
||||||
|
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
|
||||||
|
export INSTAGRAM_APP_ID="your_app_id"
|
||||||
|
export INSTAGRAM_APP_SECRET="your_app_secret"
|
||||||
|
|
||||||
|
# 실행
|
||||||
|
cd /path/to/project
|
||||||
|
python -m poc.instagram.examples.auth_example
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def example_debug_token():
|
||||||
|
"""토큰 검증 예제"""
|
||||||
|
from poc.instagram import InstagramGraphClient, AuthenticationError
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("1. 토큰 검증 (Debug Token)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
token_info = await client.debug_token()
|
||||||
|
data = token_info.data
|
||||||
|
|
||||||
|
print(f"✅ 토큰 유효: {data.is_valid}")
|
||||||
|
print(f"📱 앱 이름: {data.application}")
|
||||||
|
print(f"👤 사용자 ID: {data.user_id}")
|
||||||
|
print(f"🔐 권한: {', '.join(data.scopes)}")
|
||||||
|
print(f"⏰ 만료 시각: {data.expires_at_datetime}")
|
||||||
|
|
||||||
|
# 만료까지 남은 시간 계산
|
||||||
|
remaining = data.expires_at_datetime - datetime.now()
|
||||||
|
print(f"⏳ 남은 시간: {remaining.days}일 {remaining.seconds // 3600}시간")
|
||||||
|
|
||||||
|
if data.is_expired:
|
||||||
|
print("⚠️ 토큰이 만료되었습니다. 갱신이 필요합니다.")
|
||||||
|
|
||||||
|
except AuthenticationError as e:
|
||||||
|
print(f"❌ 인증 에러: {e}")
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"⚠️ 설정 에러: {e}")
|
||||||
|
print(" app_id와 app_secret 환경변수를 설정해주세요.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_exchange_token():
|
||||||
|
"""토큰 교환 예제 (단기 → 장기)"""
|
||||||
|
from poc.instagram import InstagramGraphClient, AuthenticationError
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("2. 토큰 교환 (Short-lived → Long-lived)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
new_token = await client.exchange_long_lived_token()
|
||||||
|
|
||||||
|
print(f"✅ 토큰 교환 성공")
|
||||||
|
print(f"🔑 새 토큰: {new_token.access_token[:20]}...")
|
||||||
|
print(f"📝 토큰 타입: {new_token.token_type}")
|
||||||
|
print(f"⏰ 유효 기간: {new_token.expires_in}초 ({new_token.expires_in // 86400}일)")
|
||||||
|
|
||||||
|
print("\n💡 새 토큰을 환경변수에 저장하세요:")
|
||||||
|
print(f' export INSTAGRAM_ACCESS_TOKEN="{new_token.access_token}"')
|
||||||
|
|
||||||
|
except AuthenticationError as e:
|
||||||
|
print(f"❌ 인증 에러: {e}")
|
||||||
|
print(" 이미 장기 토큰이거나, 토큰이 유효하지 않습니다.")
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"⚠️ 설정 에러: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_refresh_token():
|
||||||
|
"""토큰 갱신 예제"""
|
||||||
|
from poc.instagram import InstagramGraphClient, AuthenticationError
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("3. 토큰 갱신 (Long-lived Token Refresh)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
refreshed = await client.refresh_token()
|
||||||
|
|
||||||
|
print(f"✅ 토큰 갱신 성공")
|
||||||
|
print(f"🔑 갱신된 토큰: {refreshed.access_token[:20]}...")
|
||||||
|
print(f"⏰ 유효 기간: {refreshed.expires_in}초 ({refreshed.expires_in // 86400}일)")
|
||||||
|
|
||||||
|
except AuthenticationError as e:
|
||||||
|
print(f"❌ 인증 에러: {e}")
|
||||||
|
print(" 장기 토큰만 갱신 가능합니다.")
|
||||||
|
print(" 만료 24시간 전부터 갱신할 수 있습니다.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""모든 인증 예제 실행"""
|
||||||
|
print("\n🔐 Instagram Graph API 인증 예제")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 1. 토큰 검증
|
||||||
|
await example_debug_token()
|
||||||
|
|
||||||
|
# 2. 토큰 교환 (주의: 실제로 토큰이 교환됩니다)
|
||||||
|
# await example_exchange_token()
|
||||||
|
|
||||||
|
# 3. 토큰 갱신 (주의: 실제로 토큰이 갱신됩니다)
|
||||||
|
# await example_refresh_token()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✅ 예제 완료")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\n💡 토큰 교환/갱신을 테스트하려면 해당 함수의 주석을 해제하세요.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API 댓글 관리 예제
|
||||||
|
|
||||||
|
미디어의 댓글을 조회하고 답글을 작성합니다.
|
||||||
|
|
||||||
|
실행 방법:
|
||||||
|
```bash
|
||||||
|
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
|
||||||
|
python -m poc.instagram.examples.comments_example
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def example_get_comments():
|
||||||
|
"""댓글 목록 조회 예제"""
|
||||||
|
from poc.instagram import InstagramGraphClient
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("1. 댓글 목록 조회")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
# 미디어 목록에서 댓글이 있는 게시물 찾기
|
||||||
|
media_list = await client.get_media_list(limit=10)
|
||||||
|
|
||||||
|
media_with_comments = None
|
||||||
|
for media in media_list.data:
|
||||||
|
if media.comments_count > 0:
|
||||||
|
media_with_comments = media
|
||||||
|
break
|
||||||
|
|
||||||
|
if not media_with_comments:
|
||||||
|
print("⚠️ 댓글이 있는 게시물이 없습니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n📷 미디어 정보")
|
||||||
|
print("-" * 50)
|
||||||
|
print(f" ID: {media_with_comments.id}")
|
||||||
|
caption_preview = (
|
||||||
|
media_with_comments.caption[:40] + "..."
|
||||||
|
if media_with_comments.caption and len(media_with_comments.caption) > 40
|
||||||
|
else media_with_comments.caption or "(캡션 없음)"
|
||||||
|
)
|
||||||
|
print(f" 캡션: {caption_preview}")
|
||||||
|
print(f" 댓글 수: {media_with_comments.comments_count}")
|
||||||
|
|
||||||
|
# 댓글 조회
|
||||||
|
comments = await client.get_comments(
|
||||||
|
media_id=media_with_comments.id,
|
||||||
|
limit=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n💬 댓글 목록 ({len(comments.data)}개)")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
for i, comment in enumerate(comments.data, 1):
|
||||||
|
print(f"\n{i}. @{comment.username}")
|
||||||
|
print(f" 📝 {comment.text}")
|
||||||
|
print(f" ❤️ 좋아요: {comment.like_count}")
|
||||||
|
print(f" 📅 {comment.timestamp}")
|
||||||
|
|
||||||
|
# 답글이 있는 경우
|
||||||
|
if comment.replies and comment.replies.data:
|
||||||
|
print(f" 💬 답글 ({len(comment.replies.data)}개):")
|
||||||
|
for reply in comment.replies.data[:3]: # 최대 3개만 표시
|
||||||
|
print(f" └─ @{reply.username}: {reply.text[:30]}...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_find_comments_to_reply():
|
||||||
|
"""답글이 필요한 댓글 찾기"""
|
||||||
|
from poc.instagram import InstagramGraphClient
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("2. 답글이 필요한 댓글 찾기")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
# 최근 게시물 조회
|
||||||
|
media_list = await client.get_media_list(limit=5)
|
||||||
|
|
||||||
|
unanswered_comments = []
|
||||||
|
|
||||||
|
for media in media_list.data:
|
||||||
|
if media.comments_count == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
comments = await client.get_comments(media.id, limit=20)
|
||||||
|
|
||||||
|
for comment in comments.data:
|
||||||
|
# 답글이 없는 댓글 찾기
|
||||||
|
has_replies = comment.replies and len(comment.replies.data) > 0
|
||||||
|
if not has_replies:
|
||||||
|
unanswered_comments.append({
|
||||||
|
"media_id": media.id,
|
||||||
|
"comment": comment,
|
||||||
|
"media_caption": media.caption[:30] if media.caption else "(없음)",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not unanswered_comments:
|
||||||
|
print("✅ 모든 댓글에 답글이 달려있습니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n⚠️ 답글이 필요한 댓글 ({len(unanswered_comments)}개)")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
for i, item in enumerate(unanswered_comments[:10], 1): # 최대 10개
|
||||||
|
comment = item["comment"]
|
||||||
|
print(f"\n{i}. 게시물: {item['media_caption']}...")
|
||||||
|
print(f" 댓글 ID: {comment.id}")
|
||||||
|
print(f" @{comment.username}: {comment.text[:50]}...")
|
||||||
|
print(f" 📅 {comment.timestamp}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_reply_comment():
|
||||||
|
"""댓글에 답글 작성 예제 (테스트용 - 실제 게시됨)"""
|
||||||
|
from poc.instagram import InstagramGraphClient
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("3. 댓글 답글 작성 (테스트)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# ⚠️ 실제 답글이 작성되므로 주의
|
||||||
|
print(f"\n⚠️ 이 예제는 실제로 답글을 작성합니다!")
|
||||||
|
print(f" 테스트하려면 아래 코드의 주석을 해제하세요.")
|
||||||
|
|
||||||
|
# try:
|
||||||
|
# async with InstagramGraphClient() as client:
|
||||||
|
# # 먼저 답글할 댓글 찾기
|
||||||
|
# media_list = await client.get_media_list(limit=5)
|
||||||
|
#
|
||||||
|
# target_comment = None
|
||||||
|
# for media in media_list.data:
|
||||||
|
# if media.comments_count > 0:
|
||||||
|
# comments = await client.get_comments(media.id, limit=5)
|
||||||
|
# if comments.data:
|
||||||
|
# target_comment = comments.data[0]
|
||||||
|
# break
|
||||||
|
#
|
||||||
|
# if not target_comment:
|
||||||
|
# print("⚠️ 답글할 댓글을 찾을 수 없습니다.")
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# print(f"\n답글 대상 댓글:")
|
||||||
|
# print(f" ID: {target_comment.id}")
|
||||||
|
# print(f" @{target_comment.username}: {target_comment.text[:50]}...")
|
||||||
|
#
|
||||||
|
# # 답글 작성
|
||||||
|
# reply = await client.reply_comment(
|
||||||
|
# comment_id=target_comment.id,
|
||||||
|
# message="Thanks for your comment! 🙏"
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# print(f"\n✅ 답글 작성 완료!")
|
||||||
|
# print(f" 답글 ID: {reply.id}")
|
||||||
|
# print(f" 내용: {reply.text}")
|
||||||
|
#
|
||||||
|
# except Exception as e:
|
||||||
|
# print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_comment_analytics():
|
||||||
|
"""댓글 분석 예제"""
|
||||||
|
from poc.instagram import InstagramGraphClient
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("4. 댓글 분석")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
# 최근 게시물의 댓글 분석
|
||||||
|
media_list = await client.get_media_list(limit=10)
|
||||||
|
|
||||||
|
total_comments = 0
|
||||||
|
total_likes_on_comments = 0
|
||||||
|
commenters = set()
|
||||||
|
all_comments = []
|
||||||
|
|
||||||
|
for media in media_list.data:
|
||||||
|
if media.comments_count == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
comments = await client.get_comments(media.id, limit=50)
|
||||||
|
|
||||||
|
for comment in comments.data:
|
||||||
|
total_comments += 1
|
||||||
|
total_likes_on_comments += comment.like_count
|
||||||
|
if comment.username:
|
||||||
|
commenters.add(comment.username)
|
||||||
|
all_comments.append(comment)
|
||||||
|
|
||||||
|
print(f"\n📊 댓글 통계 (최근 10개 게시물)")
|
||||||
|
print("-" * 50)
|
||||||
|
print(f" 💬 총 댓글 수: {total_comments:,}")
|
||||||
|
print(f" 👥 고유 댓글 작성자: {len(commenters):,}명")
|
||||||
|
print(f" ❤️ 댓글 좋아요 합계: {total_likes_on_comments:,}")
|
||||||
|
|
||||||
|
if total_comments > 0:
|
||||||
|
avg_likes = total_likes_on_comments / total_comments
|
||||||
|
print(f" 📈 댓글당 평균 좋아요: {avg_likes:.1f}")
|
||||||
|
|
||||||
|
# 가장 좋아요가 많은 댓글
|
||||||
|
if all_comments:
|
||||||
|
top_comment = max(all_comments, key=lambda c: c.like_count)
|
||||||
|
print(f"\n🏆 가장 인기 있는 댓글")
|
||||||
|
print(f" @{top_comment.username}: {top_comment.text[:40]}...")
|
||||||
|
print(f" ❤️ {top_comment.like_count}개 좋아요")
|
||||||
|
|
||||||
|
# 가장 많이 댓글 단 사용자
|
||||||
|
if commenters:
|
||||||
|
from collections import Counter
|
||||||
|
commenter_counts = Counter(
|
||||||
|
c.username for c in all_comments if c.username
|
||||||
|
)
|
||||||
|
top_commenter = commenter_counts.most_common(1)[0]
|
||||||
|
print(f"\n🥇 가장 활발한 댓글러")
|
||||||
|
print(f" @{top_commenter[0]}: {top_commenter[1]}개 댓글")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""모든 댓글 예제 실행"""
|
||||||
|
print("\n💬 Instagram Graph API 댓글 예제")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 댓글 목록 조회
|
||||||
|
await example_get_comments()
|
||||||
|
|
||||||
|
# 답글이 필요한 댓글 찾기
|
||||||
|
await example_find_comments_to_reply()
|
||||||
|
|
||||||
|
# 답글 작성 (기본적으로 비활성화)
|
||||||
|
await example_reply_comment()
|
||||||
|
|
||||||
|
# 댓글 분석
|
||||||
|
await example_comment_analytics()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✅ 예제 완료")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API 인사이트 조회 예제
|
||||||
|
|
||||||
|
계정 및 미디어의 성과 지표를 조회합니다.
|
||||||
|
|
||||||
|
실행 방법:
|
||||||
|
```bash
|
||||||
|
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
|
||||||
|
python -m poc.instagram.examples.insights_example
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def example_account_insights():
|
||||||
|
"""계정 인사이트 조회 예제"""
|
||||||
|
from poc.instagram import InstagramGraphClient, PermissionError
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("1. 계정 인사이트 조회")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
# 일간 인사이트 조회
|
||||||
|
metrics = ["impressions", "reach", "profile_views"]
|
||||||
|
insights = await client.get_account_insights(
|
||||||
|
metrics=metrics,
|
||||||
|
period="day",
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n📊 계정 인사이트 (일간)")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
for insight in insights.data:
|
||||||
|
value = insight.latest_value
|
||||||
|
print(f"\n📈 {insight.title}")
|
||||||
|
print(f" 이름: {insight.name}")
|
||||||
|
print(f" 기간: {insight.period}")
|
||||||
|
print(f" 값: {value:,}" if isinstance(value, int) else f" 값: {value}")
|
||||||
|
if insight.description:
|
||||||
|
print(f" 설명: {insight.description[:50]}...")
|
||||||
|
|
||||||
|
# 특정 메트릭 조회
|
||||||
|
reach = insights.get_metric("reach")
|
||||||
|
if reach:
|
||||||
|
print(f"\n✅ 도달(reach) 직접 조회: {reach.latest_value:,}")
|
||||||
|
|
||||||
|
except PermissionError as e:
|
||||||
|
print(f"❌ 권한 에러: {e}")
|
||||||
|
print(" 비즈니스 계정이 필요하거나, 인사이트 권한이 없습니다.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_account_insights_periods():
|
||||||
|
"""다양한 기간의 계정 인사이트 조회"""
|
||||||
|
from poc.instagram import InstagramGraphClient
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("2. 기간별 계정 인사이트 비교")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
periods = ["day", "week", "days_28"]
|
||||||
|
metrics = ["impressions", "reach"]
|
||||||
|
|
||||||
|
for period in periods:
|
||||||
|
print(f"\n📅 기간: {period}")
|
||||||
|
print("-" * 30)
|
||||||
|
|
||||||
|
try:
|
||||||
|
insights = await client.get_account_insights(
|
||||||
|
metrics=metrics,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
for insight in insights.data:
|
||||||
|
value = insight.latest_value
|
||||||
|
print(f" {insight.name}: {value:,}" if isinstance(value, int) else f" {insight.name}: {value}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ 조회 실패: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_media_insights():
|
||||||
|
"""미디어 인사이트 조회 예제"""
|
||||||
|
from poc.instagram import InstagramGraphClient, PermissionError
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("3. 미디어 인사이트 조회")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
# 먼저 미디어 목록 조회
|
||||||
|
media_list = await client.get_media_list(limit=3)
|
||||||
|
if not media_list.data:
|
||||||
|
print("⚠️ 게시물이 없습니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for media in media_list.data:
|
||||||
|
print(f"\n📷 미디어: {media.id}")
|
||||||
|
print(f" 타입: {media.media_type}")
|
||||||
|
caption_preview = (
|
||||||
|
media.caption[:30] + "..."
|
||||||
|
if media.caption and len(media.caption) > 30
|
||||||
|
else media.caption or "(캡션 없음)"
|
||||||
|
)
|
||||||
|
print(f" 캡션: {caption_preview}")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
try:
|
||||||
|
insights = await client.get_media_insights(media.id)
|
||||||
|
|
||||||
|
for insight in insights.data:
|
||||||
|
value = insight.latest_value
|
||||||
|
print(f" 📈 {insight.name}: {value:,}" if isinstance(value, int) else f" 📈 {insight.name}: {value}")
|
||||||
|
|
||||||
|
except PermissionError as e:
|
||||||
|
print(f" ⚠️ 권한 부족: 인사이트 조회 불가")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ 조회 실패: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_media_insights_detail():
|
||||||
|
"""미디어 인사이트 상세 조회"""
|
||||||
|
from poc.instagram import InstagramGraphClient
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("4. 미디어 인사이트 상세 분석")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
# 첫 번째 미디어 가져오기
|
||||||
|
media_list = await client.get_media_list(limit=1)
|
||||||
|
if not media_list.data:
|
||||||
|
print("⚠️ 게시물이 없습니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
media = media_list.data[0]
|
||||||
|
print(f"\n📷 분석 대상 미디어")
|
||||||
|
print(f" ID: {media.id}")
|
||||||
|
print(f" 게시일: {media.timestamp}")
|
||||||
|
print(f" 좋아요: {media.like_count:,}")
|
||||||
|
print(f" 댓글: {media.comments_count:,}")
|
||||||
|
|
||||||
|
# 다양한 메트릭 조회
|
||||||
|
all_metrics = [
|
||||||
|
"impressions", # 노출 수
|
||||||
|
"reach", # 도달 수
|
||||||
|
"engagement", # 상호작용 수 (좋아요 + 댓글 + 저장)
|
||||||
|
"saved", # 저장 수
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
insights = await client.get_media_insights(
|
||||||
|
media_id=media.id,
|
||||||
|
metrics=all_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n📊 성과 분석")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
# 인사이트 값 추출
|
||||||
|
impressions = insights.get_metric("impressions")
|
||||||
|
reach = insights.get_metric("reach")
|
||||||
|
engagement = insights.get_metric("engagement")
|
||||||
|
saved = insights.get_metric("saved")
|
||||||
|
|
||||||
|
if impressions and reach:
|
||||||
|
imp_val = impressions.latest_value or 0
|
||||||
|
reach_val = reach.latest_value or 0
|
||||||
|
print(f" 👁️ 노출: {imp_val:,}")
|
||||||
|
print(f" 🎯 도달: {reach_val:,}")
|
||||||
|
if reach_val > 0:
|
||||||
|
frequency = imp_val / reach_val
|
||||||
|
print(f" 🔄 빈도: {frequency:.2f} (노출/도달)")
|
||||||
|
|
||||||
|
if engagement:
|
||||||
|
eng_val = engagement.latest_value or 0
|
||||||
|
print(f" 💪 참여: {eng_val:,}")
|
||||||
|
if reach and reach.latest_value:
|
||||||
|
eng_rate = (eng_val / reach.latest_value) * 100
|
||||||
|
print(f" 📈 참여율: {eng_rate:.2f}%")
|
||||||
|
|
||||||
|
if saved:
|
||||||
|
print(f" 💾 저장: {saved.latest_value:,}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ 인사이트 조회 실패: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""모든 인사이트 예제 실행"""
|
||||||
|
print("\n📊 Instagram Graph API 인사이트 예제")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 계정 인사이트
|
||||||
|
await example_account_insights()
|
||||||
|
|
||||||
|
# 기간별 인사이트 비교
|
||||||
|
await example_account_insights_periods()
|
||||||
|
|
||||||
|
# 미디어 인사이트
|
||||||
|
await example_media_insights()
|
||||||
|
|
||||||
|
# 미디어 인사이트 상세
|
||||||
|
await example_media_insights_detail()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✅ 예제 완료")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API 미디어 관리 예제
|
||||||
|
|
||||||
|
미디어 조회, 이미지/비디오 게시 기능을 테스트합니다.
|
||||||
|
|
||||||
|
실행 방법:
|
||||||
|
```bash
|
||||||
|
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
|
||||||
|
python -m poc.instagram.examples.media_example
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def example_get_media_list():
|
||||||
|
"""미디어 목록 조회 예제"""
|
||||||
|
from poc.instagram import InstagramGraphClient
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("1. 미디어 목록 조회")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
media_list = await client.get_media_list(limit=5)
|
||||||
|
|
||||||
|
print(f"\n📷 최근 게시물 ({len(media_list.data)}개)")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
for i, media in enumerate(media_list.data, 1):
|
||||||
|
caption_preview = (
|
||||||
|
media.caption[:40] + "..."
|
||||||
|
if media.caption and len(media.caption) > 40
|
||||||
|
else media.caption or "(캡션 없음)"
|
||||||
|
)
|
||||||
|
print(f"\n{i}. [{media.media_type}] {caption_preview}")
|
||||||
|
print(f" 🆔 ID: {media.id}")
|
||||||
|
print(f" ❤️ 좋아요: {media.like_count:,}")
|
||||||
|
print(f" 💬 댓글: {media.comments_count:,}")
|
||||||
|
print(f" 📅 게시일: {media.timestamp}")
|
||||||
|
print(f" 🔗 링크: {media.permalink}")
|
||||||
|
|
||||||
|
# 페이지네이션 정보
|
||||||
|
if media_list.paging and media_list.paging.next:
|
||||||
|
print(f"\n📄 다음 페이지 있음")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_get_media_detail():
|
||||||
|
"""미디어 상세 조회 예제"""
|
||||||
|
from poc.instagram import InstagramGraphClient
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("2. 미디어 상세 조회")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with InstagramGraphClient() as client:
|
||||||
|
# 먼저 목록에서 첫 번째 미디어 ID 가져오기
|
||||||
|
media_list = await client.get_media_list(limit=1)
|
||||||
|
if not media_list.data:
|
||||||
|
print("⚠️ 게시물이 없습니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
media_id = media_list.data[0].id
|
||||||
|
print(f"\n조회할 미디어 ID: {media_id}")
|
||||||
|
|
||||||
|
# 상세 조회
|
||||||
|
media = await client.get_media(media_id)
|
||||||
|
|
||||||
|
print(f"\n📷 미디어 상세 정보")
|
||||||
|
print("-" * 50)
|
||||||
|
print(f"🆔 ID: {media.id}")
|
||||||
|
print(f"📝 타입: {media.media_type}")
|
||||||
|
print(f"🖼️ URL: {media.media_url}")
|
||||||
|
print(f"📅 게시일: {media.timestamp}")
|
||||||
|
print(f"❤️ 좋아요: {media.like_count:,}")
|
||||||
|
print(f"💬 댓글: {media.comments_count:,}")
|
||||||
|
print(f"🔗 퍼머링크: {media.permalink}")
|
||||||
|
|
||||||
|
if media.caption:
|
||||||
|
print(f"\n📝 캡션:")
|
||||||
|
print(f" {media.caption}")
|
||||||
|
|
||||||
|
# 캐러셀인 경우 하위 미디어 표시
|
||||||
|
if media.children:
|
||||||
|
print(f"\n📚 캐러셀 하위 미디어 ({len(media.children)}개)")
|
||||||
|
for j, child in enumerate(media.children, 1):
|
||||||
|
print(f" {j}. [{child.media_type}] {child.media_url}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_publish_image():
|
||||||
|
"""이미지 게시 예제 (테스트용 - 실제 게시됨)"""
|
||||||
|
from poc.instagram import InstagramGraphClient, MediaPublishError
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("3. 이미지 게시 (테스트)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# ⚠️ 실제 게시되므로 주의
|
||||||
|
# 테스트 이미지 URL (공개 접근 가능해야 함)
|
||||||
|
TEST_IMAGE_URL = "https://example.com/test-image.jpg"
|
||||||
|
TEST_CAPTION = "Test post from Instagram Graph API POC #test"
|
||||||
|
|
||||||
|
print(f"\n⚠️ 이 예제는 실제로 게시물을 작성합니다!")
|
||||||
|
print(f" 이미지 URL: {TEST_IMAGE_URL}")
|
||||||
|
print(f" 캡션: {TEST_CAPTION}")
|
||||||
|
print(f"\n 테스트하려면 아래 코드의 주석을 해제하세요.")
|
||||||
|
|
||||||
|
# try:
|
||||||
|
# async with InstagramGraphClient() as client:
|
||||||
|
# media = await client.publish_image(
|
||||||
|
# image_url=TEST_IMAGE_URL,
|
||||||
|
# caption=TEST_CAPTION,
|
||||||
|
# )
|
||||||
|
# print(f"\n✅ 게시 완료!")
|
||||||
|
# print(f" 🆔 미디어 ID: {media.id}")
|
||||||
|
# print(f" 🔗 링크: {media.permalink}")
|
||||||
|
# except MediaPublishError as e:
|
||||||
|
# print(f"❌ 게시 실패: {e}")
|
||||||
|
# except Exception as e:
|
||||||
|
# print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_publish_video():
|
||||||
|
"""비디오 게시 예제 (테스트용 - 실제 게시됨)"""
|
||||||
|
from poc.instagram import InstagramGraphClient, MediaPublishError
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("4. 비디오/릴스 게시 (테스트)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# ⚠️ 실제 게시되므로 주의
|
||||||
|
TEST_VIDEO_URL = "https://example.com/test-video.mp4"
|
||||||
|
TEST_CAPTION = "Test video from Instagram Graph API POC #test"
|
||||||
|
|
||||||
|
print(f"\n⚠️ 이 예제는 실제로 게시물을 작성합니다!")
|
||||||
|
print(f" 비디오 URL: {TEST_VIDEO_URL}")
|
||||||
|
print(f" 캡션: {TEST_CAPTION}")
|
||||||
|
print(f"\n 테스트하려면 아래 코드의 주석을 해제하세요.")
|
||||||
|
|
||||||
|
# try:
|
||||||
|
# async with InstagramGraphClient() as client:
|
||||||
|
# media = await client.publish_video(
|
||||||
|
# video_url=TEST_VIDEO_URL,
|
||||||
|
# caption=TEST_CAPTION,
|
||||||
|
# share_to_feed=True,
|
||||||
|
# )
|
||||||
|
# print(f"\n✅ 게시 완료!")
|
||||||
|
# print(f" 🆔 미디어 ID: {media.id}")
|
||||||
|
# print(f" 🔗 링크: {media.permalink}")
|
||||||
|
# except MediaPublishError as e:
|
||||||
|
# print(f"❌ 게시 실패: {e}")
|
||||||
|
# except Exception as e:
|
||||||
|
# print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_publish_carousel():
|
||||||
|
"""캐러셀 게시 예제 (테스트용 - 실제 게시됨)"""
|
||||||
|
from poc.instagram import InstagramGraphClient, MediaPublishError
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("5. 캐러셀(멀티 이미지) 게시 (테스트)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# ⚠️ 실제 게시되므로 주의
|
||||||
|
TEST_IMAGE_URLS = [
|
||||||
|
"https://example.com/image1.jpg",
|
||||||
|
"https://example.com/image2.jpg",
|
||||||
|
"https://example.com/image3.jpg",
|
||||||
|
]
|
||||||
|
TEST_CAPTION = "Test carousel from Instagram Graph API POC #test"
|
||||||
|
|
||||||
|
print(f"\n⚠️ 이 예제는 실제로 게시물을 작성합니다!")
|
||||||
|
print(f" 이미지 수: {len(TEST_IMAGE_URLS)}개")
|
||||||
|
print(f" 캡션: {TEST_CAPTION}")
|
||||||
|
print(f"\n 테스트하려면 아래 코드의 주석을 해제하세요.")
|
||||||
|
|
||||||
|
# try:
|
||||||
|
# async with InstagramGraphClient() as client:
|
||||||
|
# media = await client.publish_carousel(
|
||||||
|
# media_urls=TEST_IMAGE_URLS,
|
||||||
|
# caption=TEST_CAPTION,
|
||||||
|
# )
|
||||||
|
# print(f"\n✅ 게시 완료!")
|
||||||
|
# print(f" 🆔 미디어 ID: {media.id}")
|
||||||
|
# print(f" 🔗 링크: {media.permalink}")
|
||||||
|
# except MediaPublishError as e:
|
||||||
|
# print(f"❌ 게시 실패: {e}")
|
||||||
|
# except Exception as e:
|
||||||
|
# print(f"❌ 에러: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""모든 미디어 예제 실행"""
|
||||||
|
print("\n📷 Instagram Graph API 미디어 예제")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 미디어 목록 조회
|
||||||
|
await example_get_media_list()
|
||||||
|
|
||||||
|
# 미디어 상세 조회
|
||||||
|
await example_get_media_detail()
|
||||||
|
|
||||||
|
# 이미지 게시 (기본적으로 비활성화)
|
||||||
|
await example_publish_image()
|
||||||
|
|
||||||
|
# 비디오 게시 (기본적으로 비활성화)
|
||||||
|
await example_publish_video()
|
||||||
|
|
||||||
|
# 캐러셀 게시 (기본적으로 비활성화)
|
||||||
|
await example_publish_carousel()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✅ 예제 완료")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API 커스텀 예외 모듈
|
||||||
|
|
||||||
|
Instagram API 에러 코드에 맞는 계층화된 예외 클래스를 정의합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramAPIError(Exception):
|
||||||
|
"""
|
||||||
|
Instagram API 기본 예외
|
||||||
|
|
||||||
|
모든 Instagram API 관련 예외의 기본 클래스입니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
message: 에러 메시지
|
||||||
|
code: Instagram API 에러 코드
|
||||||
|
subcode: Instagram API 에러 서브코드
|
||||||
|
fbtrace_id: Facebook 트레이스 ID (디버깅용)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
code: Optional[int] = None,
|
||||||
|
subcode: Optional[int] = None,
|
||||||
|
fbtrace_id: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
self.subcode = subcode
|
||||||
|
self.fbtrace_id = fbtrace_id
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
parts = [self.message]
|
||||||
|
if self.code is not None:
|
||||||
|
parts.append(f"code={self.code}")
|
||||||
|
if self.subcode is not None:
|
||||||
|
parts.append(f"subcode={self.subcode}")
|
||||||
|
if self.fbtrace_id:
|
||||||
|
parts.append(f"fbtrace_id={self.fbtrace_id}")
|
||||||
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"{self.__class__.__name__}("
|
||||||
|
f"message={self.message!r}, "
|
||||||
|
f"code={self.code}, "
|
||||||
|
f"subcode={self.subcode}, "
|
||||||
|
f"fbtrace_id={self.fbtrace_id!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationError(InstagramAPIError):
|
||||||
|
"""
|
||||||
|
인증 관련 에러
|
||||||
|
|
||||||
|
토큰이 만료되었거나, 유효하지 않거나, 앱 권한이 없는 경우 발생합니다.
|
||||||
|
|
||||||
|
관련 에러 코드:
|
||||||
|
- code=190, subcode=458: 앱에 권한 없음
|
||||||
|
- code=190, subcode=463: 토큰 만료
|
||||||
|
- code=190, subcode=467: 유효하지 않은 토큰
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(InstagramAPIError):
|
||||||
|
"""
|
||||||
|
Rate Limit 초과 에러
|
||||||
|
|
||||||
|
시간당 API 호출 제한(200회/시간/사용자)을 초과한 경우 발생합니다.
|
||||||
|
HTTP 429 응답 또는 API 에러 코드 4와 함께 발생합니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
retry_after: 재시도까지 대기해야 하는 시간 (초)
|
||||||
|
|
||||||
|
관련 에러 코드:
|
||||||
|
- code=4: Rate limit 초과
|
||||||
|
- code=17: User request limit reached
|
||||||
|
- code=341: Application request limit reached
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
retry_after: Optional[int] = None,
|
||||||
|
code: Optional[int] = 4,
|
||||||
|
subcode: Optional[int] = None,
|
||||||
|
fbtrace_id: Optional[str] = None,
|
||||||
|
):
|
||||||
|
super().__init__(message, code, subcode, fbtrace_id)
|
||||||
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
base = super().__str__()
|
||||||
|
if self.retry_after is not None:
|
||||||
|
return f"{base} | retry_after={self.retry_after}s"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramPermissionError(InstagramAPIError):
|
||||||
|
"""
|
||||||
|
권한 부족 에러
|
||||||
|
|
||||||
|
요청한 작업을 수행할 권한이 없는 경우 발생합니다.
|
||||||
|
Python 내장 PermissionError와 구분하기 위해 접두사를 사용합니다.
|
||||||
|
|
||||||
|
관련 에러 코드:
|
||||||
|
- code=10: 권한 거부됨
|
||||||
|
- code=200: 비즈니스 계정 필요
|
||||||
|
- code=230: 이 권한에 대한 앱 검토 필요
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 하위 호환성을 위한 alias (deprecated - InstagramPermissionError 사용 권장)
|
||||||
|
PermissionError = InstagramPermissionError
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPublishError(InstagramAPIError):
|
||||||
|
"""
|
||||||
|
미디어 게시 실패 에러
|
||||||
|
|
||||||
|
이미지/비디오 게시 과정에서 발생하는 에러입니다.
|
||||||
|
|
||||||
|
발생 원인:
|
||||||
|
- 이미지/비디오 URL 접근 불가
|
||||||
|
- 지원하지 않는 미디어 포맷
|
||||||
|
- 컨테이너 생성 실패
|
||||||
|
- 게시 타임아웃
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRequestError(InstagramAPIError):
|
||||||
|
"""
|
||||||
|
잘못된 요청 에러
|
||||||
|
|
||||||
|
요청 파라미터가 잘못되었거나 필수 값이 누락된 경우 발생합니다.
|
||||||
|
|
||||||
|
관련 에러 코드:
|
||||||
|
- code=100: 유효하지 않은 파라미터
|
||||||
|
- code=21009: 지원하지 않는 POST 요청
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceNotFoundError(InstagramAPIError):
|
||||||
|
"""
|
||||||
|
리소스를 찾을 수 없음 에러
|
||||||
|
|
||||||
|
요청한 미디어, 댓글, 계정 등이 존재하지 않는 경우 발생합니다.
|
||||||
|
|
||||||
|
관련 에러 코드:
|
||||||
|
- code=803: 객체가 존재하지 않음
|
||||||
|
- code=100 + subcode=33: 객체가 존재하지 않음 (다른 형태)
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerStatusError(InstagramAPIError):
|
||||||
|
"""
|
||||||
|
컨테이너 상태 에러
|
||||||
|
|
||||||
|
미디어 컨테이너가 ERROR 상태가 되었을 때 발생합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerTimeoutError(InstagramAPIError):
|
||||||
|
"""
|
||||||
|
컨테이너 타임아웃 에러
|
||||||
|
|
||||||
|
미디어 컨테이너가 지정된 시간 내에 FINISHED 상태가 되지 않은 경우 발생합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 에러 코드 → 예외 클래스 매핑
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
ERROR_CODE_MAPPING: dict[int, type[InstagramAPIError]] = {
|
||||||
|
4: RateLimitError, # Rate limit
|
||||||
|
10: InstagramPermissionError, # Permission denied
|
||||||
|
17: RateLimitError, # User request limit
|
||||||
|
100: InvalidRequestError, # Invalid parameter
|
||||||
|
190: AuthenticationError, # Invalid OAuth access token
|
||||||
|
200: InstagramPermissionError, # Requires business account
|
||||||
|
230: InstagramPermissionError, # App review required
|
||||||
|
341: RateLimitError, # Application request limit
|
||||||
|
803: ResourceNotFoundError, # Object does not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
# (code, subcode) 세부 매핑 - 더 정확한 예외 분류
|
||||||
|
ERROR_CODE_SUBCODE_MAPPING: dict[tuple[int, int], type[InstagramAPIError]] = {
|
||||||
|
(100, 33): ResourceNotFoundError, # Object does not exist
|
||||||
|
(190, 458): AuthenticationError, # App not authorized
|
||||||
|
(190, 463): AuthenticationError, # Token expired
|
||||||
|
(190, 467): AuthenticationError, # Invalid token
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_exception_from_error(
|
||||||
|
message: str,
|
||||||
|
code: Optional[int] = None,
|
||||||
|
subcode: Optional[int] = None,
|
||||||
|
fbtrace_id: Optional[str] = None,
|
||||||
|
) -> InstagramAPIError:
|
||||||
|
"""
|
||||||
|
API 에러 응답에서 적절한 예외 객체 생성
|
||||||
|
|
||||||
|
(code, subcode) 조합을 먼저 확인하고, 없으면 code만으로 매핑합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 에러 메시지
|
||||||
|
code: API 에러 코드
|
||||||
|
subcode: API 에러 서브코드
|
||||||
|
fbtrace_id: Facebook 트레이스 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
적절한 예외 클래스의 인스턴스
|
||||||
|
"""
|
||||||
|
exception_class = InstagramAPIError
|
||||||
|
|
||||||
|
# 먼저 (code, subcode) 조합으로 정확한 매핑 시도
|
||||||
|
if code is not None and subcode is not None:
|
||||||
|
key = (code, subcode)
|
||||||
|
if key in ERROR_CODE_SUBCODE_MAPPING:
|
||||||
|
exception_class = ERROR_CODE_SUBCODE_MAPPING[key]
|
||||||
|
return exception_class(
|
||||||
|
message=message,
|
||||||
|
code=code,
|
||||||
|
subcode=subcode,
|
||||||
|
fbtrace_id=fbtrace_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 기본 코드 매핑
|
||||||
|
if code is not None:
|
||||||
|
exception_class = ERROR_CODE_MAPPING.get(code, InstagramAPIError)
|
||||||
|
|
||||||
|
return exception_class(
|
||||||
|
message=message,
|
||||||
|
code=code,
|
||||||
|
subcode=subcode,
|
||||||
|
fbtrace_id=fbtrace_id,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,497 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API Pydantic 모델 모듈
|
||||||
|
|
||||||
|
API 요청/응답에 사용되는 데이터 모델을 정의합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 공통 모델
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class Paging(BaseModel):
|
||||||
|
"""
|
||||||
|
페이징 정보
|
||||||
|
|
||||||
|
Instagram API의 커서 기반 페이지네이션 정보입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cursors: Optional[dict[str, str]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="페이징 커서 (before, after)",
|
||||||
|
)
|
||||||
|
next: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="다음 페이지 URL",
|
||||||
|
)
|
||||||
|
previous: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="이전 페이지 URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 인증 모델
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TokenInfo(BaseModel):
|
||||||
|
"""
|
||||||
|
토큰 정보
|
||||||
|
|
||||||
|
액세스 토큰 교환/갱신 응답에 사용됩니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
access_token: str = Field(
|
||||||
|
...,
|
||||||
|
description="액세스 토큰",
|
||||||
|
)
|
||||||
|
token_type: str = Field(
|
||||||
|
default="bearer",
|
||||||
|
description="토큰 타입",
|
||||||
|
)
|
||||||
|
expires_in: int = Field(
|
||||||
|
...,
|
||||||
|
description="토큰 만료 시간 (초)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenDebugData(BaseModel):
|
||||||
|
"""
|
||||||
|
토큰 디버그 정보
|
||||||
|
|
||||||
|
토큰의 상세 정보를 담고 있습니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
app_id: str = Field(
|
||||||
|
...,
|
||||||
|
description="앱 ID",
|
||||||
|
)
|
||||||
|
type: str = Field(
|
||||||
|
...,
|
||||||
|
description="토큰 타입 (USER 등)",
|
||||||
|
)
|
||||||
|
application: str = Field(
|
||||||
|
...,
|
||||||
|
description="앱 이름",
|
||||||
|
)
|
||||||
|
expires_at: int = Field(
|
||||||
|
...,
|
||||||
|
description="토큰 만료 시각 (Unix timestamp)",
|
||||||
|
)
|
||||||
|
is_valid: bool = Field(
|
||||||
|
...,
|
||||||
|
description="토큰 유효 여부",
|
||||||
|
)
|
||||||
|
scopes: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="토큰에 부여된 권한 목록",
|
||||||
|
)
|
||||||
|
user_id: str = Field(
|
||||||
|
...,
|
||||||
|
description="사용자 ID",
|
||||||
|
)
|
||||||
|
data_access_expires_at: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="데이터 접근 만료 시각 (Unix timestamp)",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expires_at_datetime(self) -> datetime:
|
||||||
|
"""만료 시각을 UTC datetime으로 변환"""
|
||||||
|
return datetime.fromtimestamp(self.expires_at, tz=timezone.utc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""토큰 만료 여부 확인 (UTC 기준)"""
|
||||||
|
return datetime.now(timezone.utc).timestamp() > self.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
class TokenDebugResponse(BaseModel):
|
||||||
|
"""토큰 디버그 응답"""
|
||||||
|
|
||||||
|
data: TokenDebugData
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 계정 모델
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class AccountType(str, Enum):
|
||||||
|
"""계정 타입"""
|
||||||
|
|
||||||
|
BUSINESS = "BUSINESS"
|
||||||
|
CREATOR = "CREATOR"
|
||||||
|
PERSONAL = "PERSONAL"
|
||||||
|
|
||||||
|
|
||||||
|
class Account(BaseModel):
|
||||||
|
"""
|
||||||
|
Instagram 비즈니스/크리에이터 계정 정보
|
||||||
|
|
||||||
|
계정의 기본 정보와 통계를 포함합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = Field(
|
||||||
|
...,
|
||||||
|
description="계정 고유 ID",
|
||||||
|
)
|
||||||
|
username: str = Field(
|
||||||
|
...,
|
||||||
|
description="사용자명 (@username)",
|
||||||
|
)
|
||||||
|
name: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="계정 표시 이름",
|
||||||
|
)
|
||||||
|
account_type: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="계정 타입 (BUSINESS, CREATOR)",
|
||||||
|
)
|
||||||
|
profile_picture_url: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="프로필 사진 URL",
|
||||||
|
)
|
||||||
|
followers_count: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="팔로워 수",
|
||||||
|
)
|
||||||
|
follows_count: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="팔로잉 수",
|
||||||
|
)
|
||||||
|
media_count: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="게시물 수",
|
||||||
|
)
|
||||||
|
biography: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="자기소개",
|
||||||
|
)
|
||||||
|
website: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="웹사이트 URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 미디어 모델
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class MediaType(str, Enum):
|
||||||
|
"""미디어 타입"""
|
||||||
|
|
||||||
|
IMAGE = "IMAGE"
|
||||||
|
VIDEO = "VIDEO"
|
||||||
|
CAROUSEL_ALBUM = "CAROUSEL_ALBUM"
|
||||||
|
REELS = "REELS"
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerStatus(str, Enum):
|
||||||
|
"""미디어 컨테이너 상태"""
|
||||||
|
|
||||||
|
IN_PROGRESS = "IN_PROGRESS"
|
||||||
|
FINISHED = "FINISHED"
|
||||||
|
ERROR = "ERROR"
|
||||||
|
EXPIRED = "EXPIRED"
|
||||||
|
|
||||||
|
|
||||||
|
class Media(BaseModel):
|
||||||
|
"""
|
||||||
|
미디어 정보
|
||||||
|
|
||||||
|
이미지, 비디오, 캐러셀, 릴스 등의 미디어 정보를 담습니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = Field(
|
||||||
|
...,
|
||||||
|
description="미디어 고유 ID",
|
||||||
|
)
|
||||||
|
media_type: Optional[MediaType] = Field(
|
||||||
|
default=None,
|
||||||
|
description="미디어 타입",
|
||||||
|
)
|
||||||
|
media_url: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="미디어 URL",
|
||||||
|
)
|
||||||
|
thumbnail_url: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="썸네일 URL (비디오용)",
|
||||||
|
)
|
||||||
|
caption: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="캡션 텍스트",
|
||||||
|
)
|
||||||
|
timestamp: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
description="게시 시각",
|
||||||
|
)
|
||||||
|
permalink: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="게시물 고유 링크",
|
||||||
|
)
|
||||||
|
like_count: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="좋아요 수",
|
||||||
|
)
|
||||||
|
comments_count: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="댓글 수",
|
||||||
|
)
|
||||||
|
children: Optional[list["Media"]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="캐러셀 하위 미디어 목록",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {
|
||||||
|
"id": "17880000000000000",
|
||||||
|
"media_type": "IMAGE",
|
||||||
|
"media_url": "https://example.com/image.jpg",
|
||||||
|
"caption": "My awesome photo",
|
||||||
|
"timestamp": "2024-01-01T00:00:00+00:00",
|
||||||
|
"permalink": "https://www.instagram.com/p/ABC123/",
|
||||||
|
"like_count": 100,
|
||||||
|
"comments_count": 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MediaContainer(BaseModel):
|
||||||
|
"""
|
||||||
|
미디어 컨테이너 (게시 전 상태)
|
||||||
|
|
||||||
|
이미지/비디오 게시 시 생성되는 컨테이너의 상태 정보입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = Field(
|
||||||
|
...,
|
||||||
|
description="컨테이너 ID",
|
||||||
|
)
|
||||||
|
status_code: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="상태 코드 (IN_PROGRESS, FINISHED, ERROR)",
|
||||||
|
)
|
||||||
|
status: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="상태 상세 메시지",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_finished(self) -> bool:
|
||||||
|
"""컨테이너가 완료 상태인지 확인"""
|
||||||
|
return self.status_code == ContainerStatus.FINISHED.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_error(self) -> bool:
|
||||||
|
"""컨테이너가 에러 상태인지 확인"""
|
||||||
|
return self.status_code == ContainerStatus.ERROR.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_in_progress(self) -> bool:
|
||||||
|
"""컨테이너가 처리 중인지 확인"""
|
||||||
|
return self.status_code == ContainerStatus.IN_PROGRESS.value
|
||||||
|
|
||||||
|
|
||||||
|
class MediaList(BaseModel):
|
||||||
|
"""미디어 목록 응답"""
|
||||||
|
|
||||||
|
data: list[Media] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="미디어 목록",
|
||||||
|
)
|
||||||
|
paging: Optional[Paging] = Field(
|
||||||
|
default=None,
|
||||||
|
description="페이징 정보",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 인사이트 모델
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class InsightValue(BaseModel):
|
||||||
|
"""
|
||||||
|
인사이트 값
|
||||||
|
|
||||||
|
개별 메트릭의 값을 담습니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
value: Any = Field(
|
||||||
|
...,
|
||||||
|
description="메트릭 값 (숫자 또는 딕셔너리)",
|
||||||
|
)
|
||||||
|
end_time: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
description="측정 종료 시각",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Insight(BaseModel):
|
||||||
|
"""
|
||||||
|
인사이트 정보
|
||||||
|
|
||||||
|
계정 또는 미디어의 성과 메트릭 정보입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = Field(
|
||||||
|
...,
|
||||||
|
description="메트릭 이름",
|
||||||
|
)
|
||||||
|
period: str = Field(
|
||||||
|
...,
|
||||||
|
description="기간 (day, week, days_28, lifetime)",
|
||||||
|
)
|
||||||
|
values: list[InsightValue] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="메트릭 값 목록",
|
||||||
|
)
|
||||||
|
title: str = Field(
|
||||||
|
...,
|
||||||
|
description="메트릭 제목",
|
||||||
|
)
|
||||||
|
description: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="메트릭 설명",
|
||||||
|
)
|
||||||
|
id: str = Field(
|
||||||
|
...,
|
||||||
|
description="인사이트 ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_value(self) -> Any:
|
||||||
|
"""최신 값 반환"""
|
||||||
|
if self.values:
|
||||||
|
return self.values[-1].value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class InsightResponse(BaseModel):
|
||||||
|
"""인사이트 응답"""
|
||||||
|
|
||||||
|
data: list[Insight] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="인사이트 목록",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_metric(self, name: str) -> Optional[Insight]:
|
||||||
|
"""메트릭 이름으로 인사이트 조회"""
|
||||||
|
for insight in self.data:
|
||||||
|
if insight.name == name:
|
||||||
|
return insight
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 댓글 모델
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(BaseModel):
|
||||||
|
"""
|
||||||
|
댓글 정보
|
||||||
|
|
||||||
|
미디어에 달린 댓글 또는 답글 정보입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = Field(
|
||||||
|
...,
|
||||||
|
description="댓글 고유 ID",
|
||||||
|
)
|
||||||
|
text: str = Field(
|
||||||
|
...,
|
||||||
|
description="댓글 내용",
|
||||||
|
)
|
||||||
|
username: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="작성자 사용자명",
|
||||||
|
)
|
||||||
|
timestamp: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
description="작성 시각",
|
||||||
|
)
|
||||||
|
like_count: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="좋아요 수",
|
||||||
|
)
|
||||||
|
replies: Optional["CommentList"] = Field(
|
||||||
|
default=None,
|
||||||
|
description="답글 목록",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CommentList(BaseModel):
|
||||||
|
"""댓글 목록 응답"""
|
||||||
|
|
||||||
|
data: list[Comment] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="댓글 목록",
|
||||||
|
)
|
||||||
|
paging: Optional[Paging] = Field(
|
||||||
|
default=None,
|
||||||
|
description="페이징 정보",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 에러 응답 모델
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class APIError(BaseModel):
|
||||||
|
"""
|
||||||
|
Instagram API 에러 응답
|
||||||
|
|
||||||
|
API에서 반환하는 에러 정보입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
message: str = Field(
|
||||||
|
...,
|
||||||
|
description="에러 메시지",
|
||||||
|
)
|
||||||
|
type: str = Field(
|
||||||
|
...,
|
||||||
|
description="에러 타입",
|
||||||
|
)
|
||||||
|
code: int = Field(
|
||||||
|
...,
|
||||||
|
description="에러 코드",
|
||||||
|
)
|
||||||
|
error_subcode: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="에러 서브코드",
|
||||||
|
)
|
||||||
|
fbtrace_id: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Facebook 트레이스 ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""에러 응답 래퍼"""
|
||||||
|
|
||||||
|
error: APIError
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 모델 업데이트 (순환 참조 해결)
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
# Pydantic v2에서 순환 참조를 위한 모델 재빌드
|
||||||
|
Media.model_rebuild()
|
||||||
|
Comment.model_rebuild()
|
||||||
|
CommentList.model_rebuild()
|
||||||
Loading…
Reference in New Issue