finish poc

insta
bluebamus 2026-02-01 20:43:53 +09:00
parent f73be9c6d0
commit 18b18e9ff2
36 changed files with 9192 additions and 5439 deletions

View File

@ -161,6 +161,9 @@ uv sync
# 이미 venv를 만든 경우 (기존 가상환경 활성화 필요)
uv sync --active
playwright install
playwright install-deps
```
### 서버 실행

View File

@ -1,335 +0,0 @@
# 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 # 사용 가이드
```

View File

@ -1,694 +0,0 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"id": "e7af5103-62db-4a32-b431-6395c85d7ac9",
"metadata": {},
"outputs": [],
"source": [
"from app.home.api.routers.v1.home import crawling\n",
"from app.utils.prompts import prompts"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "6cf7ae9b-3ffe-4046-9cab-f33bc071b288",
"metadata": {},
"outputs": [],
"source": [
"from config import crawler_settings"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "4c4ec4c5-9efb-470f-99cf-a18a5b80352f",
"metadata": {},
"outputs": [],
"source": [
"from app.home.schemas.home_schema import (\n",
" CrawlingRequest,\n",
" CrawlingResponse,\n",
" ErrorResponse,\n",
" ImageUploadResponse,\n",
" ImageUploadResultItem,\n",
" ImageUrlItem,\n",
" MarketingAnalysis,\n",
" ProcessedInfo,\n",
")\n",
"import json"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "be5d0e16-8cc6-44d4-ae93-8252caa09940",
"metadata": {},
"outputs": [],
"source": [
"val1 = CrawlingRequest(**{\"url\" : 'https://map.naver.com/p/entry/place/1903455560?placePath=/home?from=map&fromPanelNum=1&additionalHeight=76&timestamp=202601131552&locale=ko&svcName=map_pcv5&businessCategory=pension&c=15.00,0,0,0,dh'})"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "c13742d7-70f4-4a6d-90c2-8b84f245a08c",
"metadata": {},
"outputs": [],
"source": [
"from app.utils.prompts.prompts import reload_all_prompt\n",
"reload_all_prompt()"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "d4db2ec1-b2af-4993-8832-47f380c17015",
"metadata": {
"scrolled": true
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"[2026-01-19 14:13:53] [INFO] [home:crawling:110] [crawling] ========== START ==========\n",
"[2026-01-19 14:13:53] [INFO] [home:crawling:111] [crawling] URL: https://map.naver.com/p/entry/place/1903455560?placePath=/home?from=map&fromPane...\n",
"[2026-01-19 14:13:53] [INFO] [home:crawling:115] [crawling] Step 1: 네이버 지도 크롤링 시작...\n",
"[2026-01-19 14:13:53] [INFO] [scraper:_call_get_accommodation:140] [NvMapScraper] Requesting place_id: 1903455560\n",
"[2026-01-19 14:13:53] [INFO] [scraper:_call_get_accommodation:149] [NvMapScraper] SUCCESS - place_id: 1903455560\n",
"[2026-01-19 14:13:51] [INFO] [home:crawling:138] [crawling] Step 1 완료 - 이미지 44개 (735.1ms)\n",
"[2026-01-19 14:13:51] [INFO] [home:crawling:142] [crawling] Step 2: 정보 가공 시작...\n",
"[2026-01-19 14:13:51] [INFO] [home:crawling:159] [crawling] Step 2 완료 - 오블로모프, 군산시 (0.8ms)\n",
"[2026-01-19 14:13:51] [INFO] [home:crawling:163] [crawling] Step 3: ChatGPT 마케팅 분석 시작...\n",
"[2026-01-19 14:13:51] [DEBUG] [home:crawling:170] [crawling] Step 3-1: 서비스 초기화 완료 (428.6ms)\n",
"build_template \n",
"[Role & Objective]\n",
"Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.\n",
"Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.\n",
"The report must clearly explain what makes the property sellable, marketable, and scalable through content.\n",
"\n",
"[INPUT]\n",
"- Business Name: {customer_name}\n",
"- Region: {region}\n",
"- Region Details: {detail_region_info}\n",
"\n",
"[Core Analysis Requirements]\n",
"Analyze the property based on:\n",
"Location, concept, and nearby environment\n",
"Target customer behavior and reservation decision factors\n",
"Include:\n",
"- Target customer segments & personas\n",
"- Unique Selling Propositions (USPs)\n",
"- Competitive landscape (direct & indirect competitors)\n",
"- Market positioning\n",
"\n",
"[Key Selling Point Structuring UI Optimized]\n",
"From the analysis above, extract the main Key Selling Points using the structure below.\n",
"Rules:\n",
"Focus only on factors that directly influence booking decisions\n",
"Each selling point must be concise and visually scannable\n",
"Language must be reusable for ads, short-form videos, and listing headlines\n",
"Avoid full sentences in descriptions; use short selling phrases\n",
"Do not provide in report\n",
"\n",
"Output format:\n",
"[Category]\n",
"(Tag keyword 5~8 words, noun-based, UI oval-style)\n",
"One-line selling phrase (not a full sentence)\n",
"Limit:\n",
"5 to 8 Key Selling Points only\n",
"Do not provide in report\n",
"\n",
"[Content & Automation Readiness Check]\n",
"Ensure that:\n",
"Each tag keyword can directly map to a content theme\n",
"Each selling phrase can be used as:\n",
"- Video hook\n",
"- Image headline\n",
"- Ad copy snippet\n",
"\n",
"\n",
"[Tag Generation Rules]\n",
"- Tags must include **only core keywords that can be directly used for viral video song lyrics**\n",
"- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind\n",
"- The number of tags must be **exactly 5**\n",
"- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited\n",
"- The following categories must be **balanced and all represented**:\n",
" 1) **Location / Local context** (region name, neighborhood, travel context)\n",
" 2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)\n",
" 3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)\n",
" 4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)\n",
" 5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)\n",
"\n",
"- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**\n",
"- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**\n",
"- The final output must strictly follow the JSON format below, with no additional text\n",
"\n",
" \"tags\": [\"Tag1\", \"Tag2\", \"Tag3\", \"Tag4\", \"Tag5\"]\n",
"\n",
"input_data {'customer_name': '오블로모프', 'region': '군산시', 'detail_region_info': '전북 군산시 절골길 16'}\n",
"[ChatgptService] Generated Prompt (length: 2791)\n",
"[2026-01-19 14:13:51] [INFO] [chatgpt:generate_structured_output:43] [ChatgptService] Starting GPT request with structured output with model: gpt-5-mini\n",
"[2026-01-19 14:14:52] [INFO] [home:crawling:187] [crawling] Step 3-3: GPT API 호출 완료 - (63233.5ms)\n",
"[2026-01-19 14:14:52] [DEBUG] [home:crawling:188] [crawling] Step 3-3: GPT API 호출 완료 - (63233.5ms)\n",
"[2026-01-19 14:14:52] [DEBUG] [home:crawling:193] [crawling] Step 3-4: 응답 파싱 시작 - facility_info: 무선 인터넷, 예약, 주차\n",
"[2026-01-19 14:14:52] [DEBUG] [home:crawling:212] [crawling] Step 3-4: 응답 파싱 완료 (2.1ms)\n",
"[2026-01-19 14:14:52] [INFO] [home:crawling:215] [crawling] Step 3 완료 - 마케팅 분석 성공 (63670.2ms)\n",
"[2026-01-19 14:14:52] [INFO] [home:crawling:229] [crawling] ========== COMPLETE ==========\n",
"[2026-01-19 14:14:52] [INFO] [home:crawling:230] [crawling] 총 소요시간: 64412.0ms\n",
"[2026-01-19 14:14:52] [INFO] [home:crawling:231] [crawling] - Step 1 (크롤링): 735.1ms\n",
"[2026-01-19 14:14:52] [INFO] [home:crawling:233] [crawling] - Step 2 (정보가공): 0.8ms\n",
"[2026-01-19 14:14:52] [INFO] [home:crawling:235] [crawling] - Step 3 (GPT 분석): 63670.2ms\n",
"[2026-01-19 14:14:52] [INFO] [home:crawling:237] [crawling] - GPT API 호출: 63233.5ms\n"
]
}
],
"source": [
"var2 = await crawling(val1)"
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "79f093f0-d7d2-4ed1-ba43-da06e4ee2073",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'image_list': ['https://ldb-phinf.pstatic.net/20230515_163/1684090233619kRU3v_JPEG/20230513_154207.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20250811_213/17548982879808X4MH_PNG/1.png',\n",
" 'https://ldb-phinf.pstatic.net/20240409_34/1712622373542UY8aC_JPEG/20231007_051403.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_37/1684090234513tT89X_JPEG/20230513_152018.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20241231_272/1735620966755B9XgT_PNG/DSC09054.png',\n",
" 'https://ldb-phinf.pstatic.net/20240409_100/1712622410472zgP15_JPEG/20230523_153219.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_151/1712623034401FzQbd_JPEG/Screenshot_20240409_093158_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_169/1712622316504ReKji_JPEG/20230728_125946.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230521_279/1684648422643NI2oj_JPEG/20230521_144343.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_52/1712622993632WR1sT_JPEG/Screenshot_20240409_093237_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20250811_151/1754898220223TNtvB_PNG/2.png',\n",
" 'https://ldb-phinf.pstatic.net/20240409_70/1712622381167p9QOI_JPEG/20230608_175722.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_144/1684090233161cR5mr_JPEG/20230513_180151.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_158/1712621983956CCqdo_JPEG/20240407_121826.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20250811_187/1754893113769iGO5X_JPEG/%B0%C5%BD%C7_01.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_31/17126219901822nnR4_JPEG/20240407_121615.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_94/1712621993863AWMKi_JPEG/20240407_121520.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_165/1684090236297fVhJM_JPEG/20230513_165348.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_102/1684090230350e1v0E_JPEG/20230513_162718.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_26/1684090232743arN2y_JPEG/20230513_174246.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20250811_273/1754893072358V3WcL_JPEG/%B5%F0%C5%D7%C0%CF%C4%C6_02.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_160/1712621974438LLNbD_JPEG/20240407_121848.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_218/1712623006036U39zE_JPEG/Screenshot_20240409_093114_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_210/16840902342654EkeL_JPEG/20230513_152107.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_216/1712623058832HBulg_JPEG/Screenshot_20240409_093309_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_184/1684090223226nO2Az_JPEG/20230514_143325.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_209/1684090697642BHNVR_JPEG/20230514_143528.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_16/1712623029052VNeaz_JPEG/Screenshot_20240409_093141_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_141/1684090233092KwtWy_JPEG/20230513_180105.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_177/1712623066424dcwJ2_JPEG/Screenshot_20240409_093511_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_181/16840902259407iA5Q_JPEG/20230514_144814.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_153/1684090224581Ih4ft_JPEG/20230514_143552.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_205/1684090231467WmulO_JPEG/20230513_180254.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20230515_120/1684090231233PkqCf_JPEG/20230513_152550.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_188/1712623039909sflvy_JPEG/Screenshot_20240409_093209_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_165/1712623049073j0TzM_JPEG/Screenshot_20240409_093254_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_3/17126230950579050V_JPEG/Screenshot_20240409_093412_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_270/1712623091524YX4E6_JPEG/Screenshot_20240409_093355_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_22/1712623083348btwTB_JPEG/Screenshot_20240409_093331_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_242/1712623087423Q7tHk_JPEG/Screenshot_20240409_093339_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_173/1712623098958aFhiB_JPEG/Screenshot_20240409_093422_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_113/1712623103270DOGKI_JPEG/Screenshot_20240409_093435_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_295/17126230704056BTRg_JPEG/Screenshot_20240409_093448_Airbnb.jpg',\n",
" 'https://ldb-phinf.pstatic.net/20240409_178/1712623075172JEt43_JPEG/Screenshot_20240409_093457_Airbnb.jpg'],\n",
" 'image_count': 44,\n",
" 'processed_info': ProcessedInfo(customer_name='오블로모프', region='군산시', detail_region_info='전북 군산시 절골길 16'),\n",
" 'marketing_analysis': MarketingAnalysis(report=MarketingAnalysisReport(summary=\"오블로모프는 '느림·쉼·문학적 감성'을 브랜드 콘셉트로 삼아 전북 군산시 절골길 인근의 조용한 주거·근대문화 접근성을 살린 소규모 부티크 스테이입니다. 도심형 접근성과 지역 근대문화·항구 관광지를 결합해 주말 단기체류, 커플·소규모 그룹, 콘텐츠 크리에이터 수요를 공략할 수 있습니다. 핵심은 브랜드 스토리(Oblomov의 느긋함)와 인스타형 비주얼, 지역 연계 체험 상품으로 예약전환을 높이는 것입니다.\", details=[MarketingAnalysisDetail(detail_title='입지·콘셉트·주변 환경', detail_description='절골길 인근의 주택가·언덕형 지형, 조용한 체류 환경. 군산 근대역사문화거리·항구·현지 시장 접근권(차로 1025분권). 문학적·레트로 감성 콘셉트(오블로모프 → 느림·휴식)으로 도심형 ‘감성 은신처’ 포지셔닝 가능.'), MarketingAnalysisDetail(detail_title='예약 결정 요인(고객 행동)', detail_description='사진·비주얼(첫 인상) → 콘셉트·프라이버시(전용공간 여부) → 접근성(차·대중교통 소요) → 가격 대비 가치·후기 → 체크인 편의성(셀프체크인 여부) → 지역 체험(먹거리·근대문화 투어) 순으로 예약 전환 영향.'), MarketingAnalysisDetail(detail_title='타깃 고객 세그먼트 & 페르소나', detail_description='1) 2040대 커플: 주말 단기여행, 인생샷·감성 중심. 2) 2030대 SNS 크리에이터/프리랜서: 콘텐츠·촬영지 탐색. 3) 소규모 가족·친구 그룹: 편안한 휴식·지역먹거리 체험. 4) 도심 직장인(원데이캉스): 근교 드라이브·힐링 목적.'), MarketingAnalysisDetail(detail_title='주요 USP(차별화 포인트)', detail_description='브랜드 스토리(Oblomov 느림의 미학), 군산 근대문화·항구 접근성, 소규모 부티크·프라이빗 체류감, 감성 포토존·인테리어로 SNS 확산 가능, 지역 먹거리·투어 연계로 체류 체감 가치 상승.'), MarketingAnalysisDetail(detail_title='경쟁 환경(직·간접 경쟁)', detail_description=\"직접: 군산 내 펜션·게스트하우스·한옥스테이(근대문화거리·항구 인근). 간접: 근교 글램핑·리조트·카페형 숙소, 당일투어(시장·박물관)로 체류대체 가능. 경쟁 우위는 '문학적 느림' 콘셉트+인스타블 친화적 비주얼.\"), MarketingAnalysisDetail(detail_title='시장 포지셔닝 제안', detail_description=\"중간 가격대의 부티크 스테이(가성비+감성), '주말 힐링·감성 촬영지' 중심 마케팅. 타깃 채널: 네이버 예약·에어비앤비·인스타그램·유튜브 숏폼. 지역 협업(카페·투어·해산물 체험)으로 패키지화.\")]), tags=['군산오블로모프', '부티크스테이', '힐링타임', '인생샷스팟', '주말여행'], facilities=['군산 근대거리·항구 근접', '문학적 느림·부티크 스테이', '프라이빗 객실·소규모 전용감', '감성 포토존·인테리어', '해산물·시장·근대투어 연계', '주말 단기여행·원데이캉스 수요'])}"
]
},
"execution_count": 7,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"var2"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "f3bf1d76-bd2a-43d5-8d39-f0ab2459701a",
"metadata": {},
"outputs": [
{
"ename": "KeyError",
"evalue": "'selling_points'",
"output_type": "error",
"traceback": [
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
"\u001b[31mKeyError\u001b[39m Traceback (most recent call last)",
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[8]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m i \u001b[38;5;129;01min\u001b[39;00m \u001b[43mvar2\u001b[49m\u001b[43m[\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mselling_points\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m]\u001b[49m:\n\u001b[32m 2\u001b[39m \u001b[38;5;28mprint\u001b[39m(i[\u001b[33m'\u001b[39m\u001b[33mcategory\u001b[39m\u001b[33m'\u001b[39m])\n\u001b[32m 3\u001b[39m \u001b[38;5;28mprint\u001b[39m(i[\u001b[33m'\u001b[39m\u001b[33mkeywords\u001b[39m\u001b[33m'\u001b[39m])\n",
"\u001b[31mKeyError\u001b[39m: 'selling_points'"
]
}
],
"source": [
"for i in var2[\"selling_points\"]:\n",
" print(i['category'])\n",
" print(i['keywords'])\n",
" print(i['description'])"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c89cf2eb-4f16-4dc5-90c6-df89191b4e39",
"metadata": {},
"outputs": [],
"source": [
"var2[\"selling_points\"]"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "231963d6-e209-41b3-8e78-2ad5d06943fe",
"metadata": {},
"outputs": [],
"source": [
"var2[\"tags\"]"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f8260222-d5a2-4018-b465-a4943c82bd3f",
"metadata": {},
"outputs": [],
"source": [
"lyric_prompt = \"\"\"\n",
"[ROLE]\n",
"You are a content marketing expert, brand strategist, and creative songwriter\n",
"specializing in Korean pension / accommodation businesses.\n",
"You create lyrics strictly based on Brand & Marketing Intelligence analysis\n",
"and optimized for viral short-form video content.\n",
"\n",
"[INPUT]\n",
"Business Name: {customer_name}\n",
"Region: {region}\n",
"Region Details: {detail_region_info}\n",
"Brand & Marketing Intelligence Report: {marketing_intelligence_summary}\n",
"Output Language: {language}\n",
"\n",
"[INTERNAL ANALYSIS DO NOT OUTPUT]\n",
"Internally analyze the following to guide all creative decisions:\n",
"- Core brand identity and positioning\n",
"- Emotional hooks derived from selling points\n",
"- Target audience lifestyle, desires, and travel motivation\n",
"- Regional atmosphere and symbolic imagery\n",
"- How the stay converts into “shareable moments”\n",
"- Which selling points must surface implicitly in lyrics\n",
"\n",
"[LYRICS & MUSIC CREATION TASK]\n",
"Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:\n",
"- Original promotional lyrics\n",
"- Music attributes for AI music generation (Suno-compatible prompt)\n",
"The output must be designed for VIRAL DIGITAL CONTENT\n",
"(short-form video, reels, ads).\n",
"\n",
"[LYRICS REQUIREMENTS]\n",
"Mandatory Inclusions:\n",
"- Business name\n",
"- Region name\n",
"- Promotion subject\n",
"- Promotional expressions including:\n",
"{promotional_expressions[language]}\n",
"\n",
"Content Rules:\n",
"- Lyrics must be emotionally driven, not descriptive listings\n",
"- Selling points must be IMPLIED, not explained\n",
"- Must sound natural when sung\n",
"- Must feel like a lifestyle moment, not an advertisement\n",
"\n",
"Tone & Style:\n",
"- Warm, emotional, and aspirational\n",
"- Trendy, viral-friendly phrasing\n",
"- Calm but memorable hooks\n",
"- Suitable for travel / stay-related content\n",
"\n",
"[SONG & MUSIC ATTRIBUTES FOR SUNO PROMPT]\n",
"After the lyrics, generate a concise music prompt including:\n",
"Song mood (emotional keywords)\n",
"BPM range\n",
"Recommended genres (max 2)\n",
"Key musical motifs or instruments\n",
"Overall vibe (1 short sentence)\n",
"\n",
"[CRITICAL LANGUAGE REQUIREMENT ABSOLUTE RULE]\n",
"ALL OUTPUT MUST BE 100% WRITTEN IN {language}.\n",
"no mixed languages\n",
"All names, places, and expressions must be in {language} \n",
"Any violation invalidates the entire output\n",
"\n",
"[OUTPUT RULES STRICT]\n",
"{timing_rules}\n",
"812 lines\n",
"Full verse flow, immersive mood\n",
"\n",
"No explanations\n",
"No headings\n",
"No bullet points\n",
"No analysis\n",
"No extra text\n",
"\n",
"[FAILURE FORMAT]\n",
"If generation is impossible:\n",
"ERROR: Brief reason in English\n",
"\"\"\"\n",
"lyric_prompt_dict = {\n",
" \"prompt_variables\" :\n",
" [\n",
" \"customer_name\",\n",
" \"region\",\n",
" \"detail_region_info\",\n",
" \"marketing_intelligence_summary\",\n",
" \"language\",\n",
" \"promotional_expression_example\",\n",
" \"timing_rules\",\n",
" \n",
" ],\n",
" \"output_format\" : {\n",
" \"format\": {\n",
" \"type\": \"json_schema\",\n",
" \"name\": \"lyric\",\n",
" \"schema\": {\n",
" \"type\":\"object\",\n",
" \"properties\" : {\n",
" \"lyric\" : { \n",
" \"type\" : \"string\"\n",
" }\n",
" },\n",
" \"required\": [\"lyric\"],\n",
" \"additionalProperties\": False,\n",
" },\n",
" \"strict\": True\n",
" }\n",
" }\n",
"}"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "79edd82b-6f4c-43c7-9205-0b970afe06d7",
"metadata": {},
"outputs": [],
"source": [
"\n",
"with open(\"./app/utils/prompts/marketing_prompt.txt\", \"w\") as fp:\n",
" fp.write(marketing_prompt)"
]
},
{
"cell_type": "code",
"execution_count": 17,
"id": "65a5a2a6-06a5-4ee1-a796-406c86aefc20",
"metadata": {},
"outputs": [],
"source": [
"with open(\"prompts/summarize_prompt.json\", \"r\") as fp:\n",
" p = json.load(fp)"
]
},
{
"cell_type": "code",
"execution_count": 18,
"id": "454d920f-e9ed-4fb2-806c-75b8f7033db9",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'prompt_variables': ['report', 'selling_points'],\n",
" 'prompt': '\\n입력 : \\n분석 보고서\\n{report}\\n\\n셀링 포인트\\n{selling_points}\\n\\n위 분석 결과를 바탕으로, 주요 셀링 포인트를 다음 구조로 재정리하라.\\n\\n조건:\\n각 셀링 포인트는 반드시 ‘카테고리 → 태그 키워드 → 한 줄 설명’ 구조를 가질 것\\n태그 키워드는 UI 상에서 타원(oval) 형태의 시각적 태그로 사용될 것을 가정하여\\n- 3 ~ 6단어 이내\\n- 명사 또는 명사형 키워드로 작성\\n- 설명은 문장이 아닌, 짧은 ‘셀링 문구’ 형태로 작성할 것\\n- 광고·숏폼·상세페이지 어디에도 바로 재사용 가능해야 함\\n- 전체 셀링 포인트 개수는 5~7개로 제한\\n\\n출력 형식:\\n[카테고리명]\\n(태그 키워드)\\n- 한 줄 설명 문구\\n\\n예시: \\n[공간 정체성]\\n(100년 적산가옥 · 시간의 결)\\n- 하루를 ‘숙박’이 아닌 ‘체류’로 바꾸는 공간\\n\\n[입지 & 희소성]\\n(말랭이마을 · 로컬 히든플레이스)\\n- 관광지가 아닌, 군산을 아는 사람의 선택\\n\\n[프라이버시]\\n(독채 숙소 · 프라이빗 스테이)\\n- 누구의 방해도 없는 완전한 휴식 구조\\n\\n[비주얼 경쟁력]\\n(감성 인테리어 · 자연광 스폿)\\n- 찍는 순간 콘텐츠가 되는 공간 설계\\n\\n[타깃 최적화]\\n(커플 · 소규모 여행)\\n- 둘에게 가장 이상적인 공간 밀도\\n\\n[체류 경험]\\n(아무것도 안 해도 되는 하루)\\n- 일정 없이도 만족되는 하루 루틴\\n\\n[브랜드 포지션]\\n(호텔도 펜션도 아닌 아지트)\\n- 다시 돌아오고 싶은 개인적 장소\\n ',\n",
" 'output_format': {'format': {'type': 'json_schema',\n",
" 'name': 'tags',\n",
" 'schema': {'type': 'object',\n",
" 'properties': {'category': {'type': 'string'},\n",
" 'tag_keywords': {'type': 'string'},\n",
" 'description': {'type': 'string'}},\n",
" 'required': ['category', 'tag_keywords', 'description'],\n",
" 'additionalProperties': False},\n",
" 'strict': True}}}"
]
},
"execution_count": 18,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"p"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "c46abcda-d6a8-485e-92f1-526fb28c6b53",
"metadata": {},
"outputs": [],
"source": [
"import json\n",
"marketing_prompt_dict = {\n",
" \"model\" : \"gpt-5-mini\",\n",
" \"prompt_variables\" :\n",
" [\n",
" \"customer_name\",\n",
" \"region\",\n",
" \"detail_region_info\"\n",
" ],\n",
" \"output_format\" : {\n",
" \"format\": {\n",
" \"type\": \"json_schema\",\n",
" \"name\": \"report\",\n",
" \"schema\": {\n",
" \"type\" : \"object\",\n",
" \"properties\" : {\n",
" \"report\" : {\n",
" \"type\": \"object\",\n",
" \"properties\" : {\n",
" \"summary\" : {\"type\" : \"string\"},\n",
" \"details\" : {\n",
" \"type\" : \"array\",\n",
" \"items\" : {\n",
" \"type\": \"object\",\n",
" \"properties\" : {\n",
" \"detail_title\" : {\"type\" : \"string\"},\n",
" \"detail_description\" : {\"type\" : \"string\"},\n",
" },\n",
" \"required\": [\"detail_title\", \"detail_description\"],\n",
" \"additionalProperties\": False,\n",
" }\n",
" }\n",
" },\n",
" \"required\" : [\"summary\", \"details\"],\n",
" \"additionalProperties\" : False\n",
" },\n",
" \"selling_points\" : {\n",
" \"type\": \"array\",\n",
" \"items\": {\n",
" \"type\": \"object\",\n",
" \"properties\" : {\n",
" \"category\" : {\"type\" : \"string\"},\n",
" \"keywords\" : {\"type\" : \"string\"},\n",
" \"description\" : {\"type\" : \"string\"}\n",
" },\n",
" \"required\": [\"category\", \"keywords\", \"description\"],\n",
" \"additionalProperties\": False,\n",
" },\n",
" },\n",
" \"tags\" : {\n",
" \"type\": \"array\",\n",
" \"items\": {\n",
" \"type\": \"string\"\n",
" },\n",
" },\n",
" \"contents_advise\" : {\"type\" : \"string\"}\n",
" },\n",
" \"required\": [\"report\", \"selling_points\", \"tags\", \"contents_advise\"],\n",
" \"additionalProperties\": False,\n",
" },\n",
" \"strict\": True\n",
" }\n",
" }\n",
"}\n",
"with open(\"./app/utils/prompts/marketing_prompt.json\", \"w\") as fp:\n",
" json.dump(marketing_prompt_dict, fp, ensure_ascii=False)"
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "c3867dab-0c4e-46be-ad12-a9c02b5edb68",
"metadata": {},
"outputs": [],
"source": [
"lyric_prompt = \"\"\"\n",
"[ROLE]\n",
"You are a content marketing expert, brand strategist, and creative songwriter\n",
"specializing in Korean pension / accommodation businesses.\n",
"You create lyrics strictly based on Brand & Marketing Intelligence analysis\n",
"and optimized for viral short-form video content.\n",
"\n",
"[INPUT]\n",
"Business Name: {customer_name}\n",
"Region: {region}\n",
"Region Details: {detail_region_info}\n",
"Brand & Marketing Intelligence Report: {marketing_intelligence_summary}\n",
"Output Language: {language}\n",
"\n",
"[INTERNAL ANALYSIS DO NOT OUTPUT]\n",
"Internally analyze the following to guide all creative decisions:\n",
"- Core brand identity and positioning\n",
"- Emotional hooks derived from selling points\n",
"- Target audience lifestyle, desires, and travel motivation\n",
"- Regional atmosphere and symbolic imagery\n",
"- How the stay converts into “shareable moments”\n",
"- Which selling points must surface implicitly in lyrics\n",
"\n",
"[LYRICS & MUSIC CREATION TASK]\n",
"Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:\n",
"- Original promotional lyrics\n",
"- Music attributes for AI music generation (Suno-compatible prompt)\n",
"The output must be designed for VIRAL DIGITAL CONTENT\n",
"(short-form video, reels, ads).\n",
"\n",
"[LYRICS REQUIREMENTS]\n",
"Mandatory Inclusions:\n",
"- Business name\n",
"- Region name\n",
"- Promotion subject\n",
"- Promotional expressions including:\n",
"{promotional_expressions[language]}\n",
"\n",
"Content Rules:\n",
"- Lyrics must be emotionally driven, not descriptive listings\n",
"- Selling points must be IMPLIED, not explained\n",
"- Must sound natural when sung\n",
"- Must feel like a lifestyle moment, not an advertisement\n",
"\n",
"Tone & Style:\n",
"- Warm, emotional, and aspirational\n",
"- Trendy, viral-friendly phrasing\n",
"- Calm but memorable hooks\n",
"- Suitable for travel / stay-related content\n",
"\n",
"[SONG & MUSIC ATTRIBUTES FOR SUNO PROMPT]\n",
"After the lyrics, generate a concise music prompt including:\n",
"Song mood (emotional keywords)\n",
"BPM range\n",
"Recommended genres (max 2)\n",
"Key musical motifs or instruments\n",
"Overall vibe (1 short sentence)\n",
"\n",
"[CRITICAL LANGUAGE REQUIREMENT ABSOLUTE RULE]\n",
"ALL OUTPUT MUST BE 100% WRITTEN IN {language}.\n",
"no mixed languages\n",
"All names, places, and expressions must be in {language} \n",
"Any violation invalidates the entire output\n",
"\n",
"[OUTPUT RULES STRICT]\n",
"{timing_rules}\n",
"812 lines\n",
"Full verse flow, immersive mood\n",
"\n",
"No explanations\n",
"No headings\n",
"No bullet points\n",
"No analysis\n",
"No extra text\n",
"\n",
"[FAILURE FORMAT]\n",
"If generation is impossible:\n",
"ERROR: Brief reason in English\n",
"\"\"\"\n",
"with open(\"./app/utils/prompts/lyric_prompt.txt\", \"w\") as fp:\n",
" fp.write(lyric_prompt)"
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "5736ca4b-c379-4cae-84a9-534cad9576c7",
"metadata": {},
"outputs": [],
"source": [
"lyric_prompt_dict = {\n",
" \"model\" : \"gpt-5-mini\",\n",
" \"prompt_variables\" :\n",
" [\n",
" \"customer_name\",\n",
" \"region\",\n",
" \"detail_region_info\",\n",
" \"marketing_intelligence_summary\",\n",
" \"language\",\n",
" \"promotional_expression_example\",\n",
" \"timing_rules\",\n",
" \n",
" ],\n",
" \"output_format\" : {\n",
" \"format\": {\n",
" \"type\": \"json_schema\",\n",
" \"name\": \"lyric\",\n",
" \"schema\": {\n",
" \"type\":\"object\",\n",
" \"properties\" : {\n",
" \"lyric\" : { \n",
" \"type\" : \"string\"\n",
" }\n",
" },\n",
" \"required\": [\"lyric\"],\n",
" \"additionalProperties\": False,\n",
" },\n",
" \"strict\": True\n",
" }\n",
" }\n",
"}\n",
"with open(\"./app/utils/prompts/lyric_prompt.json\", \"w\") as fp:\n",
" json.dump(lyric_prompt_dict, fp, ensure_ascii=False)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "430c8914-4e6a-4b53-8903-f454e7ccb8e2",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.8"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

238
plan.md Normal file
View File

@ -0,0 +1,238 @@
# Instagram POC 개발 계획서
## 프로젝트 개요
Instagram Graph API를 사용하여 이미지, 영상, 컨텐츠를 업로드하고 결과를 확인하는 POC 모듈 개발
## 목표
- 단일 파일, 단일 클래스로 Instagram API 기능 구현
- 여러 사용자가 각자의 계정으로 컨텐츠 업로드 가능 (멀티테넌트)
- API 예외처리 및 에러 핸들링
- 테스트 파일 및 사용 매뉴얼 제공
## 참고 자료
1. **기존 코드**: `poc/instagram1/` 폴더
2. **공식 문서**: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing
## 기존 코드(instagram1) 분석 결과
### 발견된 문제점 (모두 수정 완료 ✅)
| 심각도 | 문제 | 설명 | 상태 |
|--------|------|------|------|
| 🔴 Critical | 잘못된 import 경로 | `poc.instagram``poc.instagram1`로 수정 | ✅ 완료 |
| 🔴 Critical | Timezone 혼합 | `datetime.now()``datetime.now(timezone.utc)`로 수정 | ✅ 완료 |
| 🟡 Warning | 파일 중간 import | `config.py`에서 `lru_cache` import를 파일 상단으로 이동 | ✅ 완료 |
| 🟡 Warning | Deprecated alias 사용 | `PermissionError``InstagramPermissionError`로 변경 | ✅ 완료 |
| 🟡 Warning | docstring 경로 오류 | `__init__.py` 예제 경로 수정 | ✅ 완료 |
### 수정된 파일
- `poc/instagram1/config.py` - import 위치 수정
- `poc/instagram1/__init__.py` - docstring 경로 수정
- `poc/instagram1/examples/auth_example.py` - timezone 및 import 경로 수정
- `poc/instagram1/examples/account_example.py` - import 경로 수정
- `poc/instagram1/examples/comments_example.py` - import 경로 수정
- `poc/instagram1/examples/insights_example.py` - import 경로 및 deprecated alias 수정
- `poc/instagram1/examples/media_example.py` - import 경로 수정
## 산출물
```
poc/instagram/
├── client.py # InstagramClient 클래스 + 예외 클래스
├── main.py # 테스트 실행 파일
└── poc.md # 사용 매뉴얼
```
## 필수 기능
1. **이미지 업로드** - 단일 이미지 게시
2. **영상(릴스) 업로드** - 비디오 게시
3. **캐러셀 업로드** - 멀티 이미지 게시 (2-10개)
4. **미디어 조회** - 업로드된 게시물 확인
5. **예외처리** - API 에러 코드별 처리
## 설계 요구사항
### 클래스 구조
```python
class InstagramClient:
"""
Instagram Graph API 클라이언트
- 인스턴스 생성 시 access_token 전달 (멀티테넌트 지원)
- 비동기 컨텍스트 매니저 패턴
"""
def __init__(self, access_token: str): ...
async def publish_image(self, image_url: str, caption: str) -> Media: ...
async def publish_video(self, video_url: str, caption: str) -> Media: ...
async def publish_carousel(self, media_urls: list[str], caption: str) -> Media: ...
async def get_media(self, media_id: str) -> Media: ...
async def get_media_list(self, limit: int) -> list[Media]: ...
```
### 예외 클래스
```python
class InstagramAPIError(Exception): ... # 기본 예외
class AuthenticationError(InstagramAPIError): ... # 인증 오류
class RateLimitError(InstagramAPIError): ... # Rate Limit 초과
class MediaPublishError(InstagramAPIError): ... # 게시 실패
class InvalidRequestError(InstagramAPIError): ... # 잘못된 요청
```
---
## 에이전트 워크플로우
### 1단계: 설계 에이전트 (`/design`)
```
/design
## 요청 개요
Instagram Graph API를 사용하여 이미지, 영상, 컨텐츠를 업로드하고 결과를 확인하는 POC 모듈 설계
## 참고 자료
1. Instagram Graph API 공식 문서: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing
2. 기존 코드: poc/instagram1/ 폴더 내용
## 요구사항
1. poc/instagram/ 폴더에 단일 파일, 단일 클래스로 구현
2. 여러 사용자가 각자의 계정으로 컨텐츠 업로드 가능하도록 설계 (멀티테넌트)
3. 필수 기능:
- 이미지 업로드
- 영상(릴스) 업로드
- 캐러셀(멀티 이미지) 업로드
- 업로드된 미디어 조회
4. Instagram API 에러 코드별 예외처리
5. main.py 테스트 파일 포함
6. poc.md 사용 매뉴얼 문서 포함
## 산출물
- 클래스 구조 및 메서드 시그니처
- 예외 클래스 설계
- 파일 구조
```
---
### 2단계: 개발 에이전트 (`/develop`)
```
/develop
## 작업 내용
1단계 설계를 기반으로 Instagram POC 모듈 구현
## 구현 대상
1. poc/instagram/client.py
- InstagramClient 클래스 (단일 클래스로 모든 기능 포함)
- 예외 클래스들 (같은 파일 내 정의)
- 기능별 주석 필수
2. poc/instagram/main.py
- 테스트 코드 (import하여 각 기능 테스트)
- 환경변수 기반 토큰 설정
3. poc/instagram/poc.md
- 동작 원리 설명
- 환경 설정 방법
- 사용 예제 코드
- API 제한사항
## 참고
- poc/instagram1/ 폴더의 기존 코드 참고
- Instagram Graph API 공식 문서 기반으로 구현
- 잘못된 import 경로, timezone 문제 등 기존 코드의 버그 수정 반영
```
---
### 3단계: 코드리뷰 에이전트 (`/review`)
```
/review
## 리뷰 대상
poc/instagram/ 폴더의 모든 파일
## 리뷰 항목
1. 코드 품질
- PEP 8 준수 여부
- 타입 힌트 적용
- 비동기 패턴 적절성
2. 기능 완성도
- 이미지/영상/캐러셀 업로드 기능
- 미디어 조회 기능
- 멀티테넌트 지원
3. 예외처리
- API 에러 코드별 처리
- Rate Limit 처리
- 타임아웃 처리
4. 문서화
- 주석의 적절성
- poc.md 완성도
5. 보안
- 토큰 노출 방지
- 민감 정보 로깅 마스킹
## instagram1 대비 개선점 확인
- import 경로 오류 수정됨
- timezone aware/naive 혼합 문제 수정됨
- deprecated alias 제거됨
```
---
## 실행 순서
```bash
# 1. 설계 단계
/design
# 2. 개발 단계 (설계 승인 후)
/develop
# 3. 코드 리뷰 단계 (개발 완료 후)
/review
```
---
## 환경 설정
### 필수 환경변수
```bash
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
export INSTAGRAM_APP_ID="your_app_id" # 선택
export INSTAGRAM_APP_SECRET="your_app_secret" # 선택
```
### 의존성
```bash
uv add httpx pydantic pydantic-settings
```
---
## 일정
| 단계 | 작업 | 상태 |
|------|------|------|
| 1 | 설계 (`/design`) | ✅ 완료 |
| 2 | 개발 (`/develop`) | ✅ 완료 |
| 3 | 코드리뷰 (`/review`) | ⬜ 대기 |
| 4 | 테스트 및 검증 | ⬜ 대기 |

View File

@ -1,99 +1,49 @@
"""
Instagram Graph API POC 패키지
Instagram Graph API와의 통신을 위한 비동기 클라이언트를 제공합니다.
단일 클래스로 구현된 Instagram Graph API 클라이언트입니다.
Example:
```python
from poc.instagram import InstagramGraphClient
from poc.instagram import InstagramClient
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}")
async with InstagramClient(access_token="YOUR_TOKEN") as client:
media = await client.publish_image(
image_url="https://example.com/image.jpg",
caption="Hello!"
)
```
"""
from .client import InstagramGraphClient
from .config import InstagramSettings, get_settings, settings
from .exceptions import (
from poc.instagram.client import InstagramClient
from poc.instagram.exceptions import (
InstagramAPIError,
AuthenticationError,
RateLimitError,
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,
from poc.instagram.models import (
Media,
MediaContainer,
MediaList,
MediaType,
Paging,
TokenDebugData,
TokenDebugResponse,
TokenInfo,
MediaContainer,
APIError,
ErrorResponse,
)
__all__ = [
# Client
"InstagramGraphClient",
# Config
"InstagramSettings",
"get_settings",
"settings",
"InstagramClient",
# Exceptions
"InstagramAPIError",
"AuthenticationError",
"RateLimitError",
"InstagramPermissionError",
"PermissionError", # deprecated alias
"MediaPublishError",
"InvalidRequestError",
"ResourceNotFoundError",
"ContainerStatusError",
"ContainerTimeoutError",
# Models - Auth
"TokenInfo",
"TokenDebugData",
"TokenDebugResponse",
# Models - Account
"Account",
"AccountType",
# Models - Media
# Models
"Media",
"MediaType",
"MediaContainer",
"ContainerStatus",
"MediaList",
# Models - Insight
"Insight",
"InsightValue",
"InsightResponse",
# Models - Comment
"Comment",
"CommentList",
# Models - Common
"Paging",
"MediaContainer",
"APIError",
"ErrorResponse",
]

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
"""
Instagram Graph API 커스텀 예외 모듈
Instagram API 에러 코드에 맞는 계층화된 예외 클래스를 정의합니다.
Instagram API 에러 코드에 맞는 예외 클래스를 정의합니다.
"""
from typing import Optional
@ -43,26 +43,12 @@ class InstagramAPIError(Exception):
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
@ -72,16 +58,10 @@ class RateLimitError(InstagramAPIError):
"""
Rate Limit 초과 에러
시간당 API 호출 제한(200/시간/사용자) 초과한 경우 발생합니다.
HTTP 429 응답 또는 API 에러 코드 4 함께 발생합니다.
시간당 API 호출 제한을 초과한 경우 발생합니다.
Attributes:
retry_after: 재시도까지 대기해야 하는 시간 ()
관련 에러 코드:
- code=4: Rate limit 초과
- code=17: User request limit reached
- code=341: Application request limit reached
"""
def __init__(
@ -102,70 +82,6 @@ class RateLimitError(InstagramAPIError):
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):
"""
컨테이너 상태 에러
@ -186,28 +102,12 @@ class ContainerTimeoutError(InstagramAPIError):
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
4: RateLimitError,
17: RateLimitError,
190: AuthenticationError,
341: RateLimitError,
}
@ -220,8 +120,6 @@ def create_exception_from_error(
"""
API 에러 응답에서 적절한 예외 객체 생성
(code, subcode) 조합을 먼저 확인하고, 없으면 code만으로 매핑합니다.
Args:
message: 에러 메시지
code: API 에러 코드
@ -233,19 +131,6 @@ def create_exception_from_error(
"""
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)

92
poc/instagram/main.py Normal file
View File

@ -0,0 +1,92 @@
"""
Instagram Graph API POC - 비디오 업로드 테스트
실행 방법:
python -m poc.instagram.main
"""
import asyncio
import logging
import sys
from poc.instagram.client import InstagramClient
from poc.instagram.exceptions import InstagramAPIError
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
# 설정
ACCESS_TOKEN = "IGAAde0ToiLW1BZAFpTaTBVNEJGMksyV25XY01SMzNHU29RRFJmc25hcXJReUtpbVJvTVNaS2ZAESE92NFlNTS1qazNOLVlSRlJuYTZAoTWFtS2tkSGJYblBPZAVdfZAWNfOGkyY0o2TDBSekdIaUd6WjNaUHZAXb1R0M05YdjRTcTNyNAZDZD"
VIDEO_URL = "https://f002.backblazeb2.com/file/creatomate-c8xg3hsxdu/9b1a680b-3481-4b22-94d4-a5cfd3e19f95.mp4"
VIDEO_CAPTION = "Test video from Instagram POC #test"
async def main():
"""비디오 업로드 POC 실행"""
print("\n" + "=" * 60)
print("Instagram Graph API - 비디오 업로드 POC")
print("=" * 60)
async with InstagramClient(access_token=ACCESS_TOKEN) as client:
# Step 1: 접속 테스트
print("\n[Step 1] 접속 테스트")
print("-" * 40)
try:
account_id = await client.get_account_id()
print("[성공] 접속 확인 완료")
print(f" Account ID: {account_id}")
except InstagramAPIError as e:
print(f"[실패] 접속 실패: {e}")
return
# Step 2: 비디오 업로드
print("\n[Step 2] 비디오 업로드")
print("-" * 40)
print(f" 비디오 URL: {VIDEO_URL}")
print(f" 캡션: {VIDEO_CAPTION}")
print("\n업로드 중... (비디오 처리에 시간이 걸릴 수 있습니다)")
try:
media = await client.publish_video(
video_url=VIDEO_URL,
caption=VIDEO_CAPTION,
share_to_feed=True,
)
print("\n[성공] 비디오 업로드 완료!")
print(f" 미디어 ID: {media.id}")
print(f" 링크: {media.permalink}")
except InstagramAPIError as e:
print(f"\n[실패] 업로드 실패: {e}")
return
# Step 3: 업로드 확인
print("\n[Step 3] 업로드 확인")
print("-" * 40)
try:
verified_media = await client.get_media(media.id)
print("[성공] 업로드 확인 완료!")
print(f" 미디어 ID: {verified_media.id}")
print(f" 타입: {verified_media.media_type}")
print(f" URL: {verified_media.media_url}")
print(f" 퍼머링크: {verified_media.permalink}")
print(f" 게시일: {verified_media.timestamp}")
if verified_media.caption:
print(f" 캡션: {verified_media.caption}")
except InstagramAPIError as e:
print(f"[실패] 확인 실패: {e}")
return
print("\n" + "=" * 60)
print("모든 단계 완료!")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

329
poc/instagram/main_ori.py Normal file
View File

@ -0,0 +1,329 @@
"""
Instagram Graph API POC 테스트
파일은 InstagramClient의 기능을 테스트합니다.
실행 방법:
```bash
# 환경변수 설정
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
# 실행
python -m poc.instagram.main
```
주의사항:
- 게시 테스트는 실제로 Instagram에 게시됩니다.
- 테스트 토큰이 올바른지 확인하세요.
"""
import asyncio
import logging
import os
import sys
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
def get_access_token() -> str:
"""환경변수에서 액세스 토큰 가져오기"""
token = os.environ.get("INSTAGRAM_ACCESS_TOKEN")
if not token:
print("=" * 60)
print("오류: INSTAGRAM_ACCESS_TOKEN 환경변수가 설정되지 않았습니다.")
print()
print("설정 방법:")
print(" Windows PowerShell:")
print(' $env:INSTAGRAM_ACCESS_TOKEN = "your_token_here"')
print()
print(" Windows CMD:")
print(' set INSTAGRAM_ACCESS_TOKEN=your_token_here')
print()
print(" Linux/macOS:")
print(' export INSTAGRAM_ACCESS_TOKEN="your_token_here"')
print("=" * 60)
sys.exit(1)
return token
async def test_get_media_list():
"""미디어 목록 조회 테스트"""
from poc.instagram.client import InstagramClient
print("\n" + "=" * 60)
print("1. 미디어 목록 조회 테스트")
print("=" * 60)
access_token = get_access_token()
try:
async with InstagramClient(access_token=access_token) 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.next_cursor:
print(f"\n다음 페이지 있음 (cursor: {media_list.next_cursor[:20]}...)")
print("\n[성공] 미디어 목록 조회 완료")
except Exception as e:
print(f"\n[실패] 에러: {e}")
raise
async def test_get_media_detail():
"""미디어 상세 조회 테스트"""
from poc.instagram.client import InstagramClient
print("\n" + "=" * 60)
print("2. 미디어 상세 조회 테스트")
print("=" * 60)
access_token = get_access_token()
try:
async with InstagramClient(access_token=access_token) as client:
# 먼저 목록에서 첫 번째 미디어 ID 가져오기
media_list = await client.get_media_list(limit=1)
if not media_list.data:
print("\n게시물이 없습니다.")
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}")
print("\n[성공] 미디어 상세 조회 완료")
except Exception as e:
print(f"\n[실패] 에러: {e}")
raise
async def test_publish_image():
"""이미지 게시 테스트 (주석 처리됨 - 실제 게시됨)"""
from poc.instagram.client import InstagramClient
print("\n" + "=" * 60)
print("3. 이미지 게시 테스트")
print("=" * 60)
# 테스트 설정 (공개 접근 가능한 이미지 URL 필요)
TEST_IMAGE_URL = "https://example.com/test-image.jpg"
TEST_CAPTION = "Test post from Instagram POC #test"
print(f"\n이 테스트는 실제로 게시물을 작성합니다!")
print(f" 이미지 URL: {TEST_IMAGE_URL}")
print(f" 캡션: {TEST_CAPTION}")
print(f"\n테스트하려면 아래 코드의 주석을 해제하세요.")
print("[건너뜀] 이미지 게시 테스트 (주석 처리됨)")
# ==========================================================================
# 실제 테스트 - 주석 해제 시 실행됨
# ==========================================================================
# from poc.instagram.exceptions import InstagramAPIError
# access_token = get_access_token()
#
# try:
# async with InstagramClient(access_token=access_token) 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 InstagramAPIError as e:
# print(f"\n[실패] 게시 실패: {e}")
# except Exception as e:
# print(f"\n[실패] 에러: {e}")
async def test_publish_video():
"""비디오/릴스 게시 테스트 (주석 처리됨 - 실제 게시됨)"""
from poc.instagram.client import InstagramClient
print("\n" + "=" * 60)
print("4. 비디오/릴스 게시 테스트")
print("=" * 60)
TEST_VIDEO_URL = "https://example.com/test-video.mp4"
TEST_CAPTION = "Test video from Instagram POC #test"
print(f"\n이 테스트는 실제로 게시물을 작성합니다!")
print(f" 비디오 URL: {TEST_VIDEO_URL}")
print(f" 캡션: {TEST_CAPTION}")
print(f"\n테스트하려면 아래 코드의 주석을 해제하세요.")
print("[건너뜀] 비디오 게시 테스트 (주석 처리됨)")
# ==========================================================================
# 실제 테스트 - 주석 해제 시 실행됨
# ==========================================================================
# from poc.instagram.exceptions import InstagramAPIError
# access_token = get_access_token()
#
# try:
# async with InstagramClient(access_token=access_token) 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 InstagramAPIError as e:
# print(f"\n[실패] 게시 실패: {e}")
# except Exception as e:
# print(f"\n[실패] 에러: {e}")
async def test_publish_carousel():
"""캐러셀 게시 테스트 (주석 처리됨 - 실제 게시됨)"""
from poc.instagram.client import InstagramClient
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 POC #test"
print(f"\n이 테스트는 실제로 게시물을 작성합니다!")
print(f" 이미지 수: {len(TEST_IMAGE_URLS)}")
print(f" 캡션: {TEST_CAPTION}")
print(f"\n테스트하려면 아래 코드의 주석을 해제하세요.")
print("[건너뜀] 캐러셀 게시 테스트 (주석 처리됨)")
# ==========================================================================
# 실제 테스트 - 주석 해제 시 실행됨
# ==========================================================================
# from poc.instagram.exceptions import InstagramAPIError
# access_token = get_access_token()
#
# try:
# async with InstagramClient(access_token=access_token) 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 InstagramAPIError as e:
# print(f"\n[실패] 게시 실패: {e}")
# except Exception as e:
# print(f"\n[실패] 에러: {e}")
async def test_error_handling():
"""에러 처리 테스트"""
from poc.instagram.client import InstagramClient
from poc.instagram.exceptions import (
AuthenticationError,
InstagramAPIError,
RateLimitError,
)
print("\n" + "=" * 60)
print("6. 에러 처리 테스트")
print("=" * 60)
# 잘못된 토큰으로 테스트
print("\n잘못된 토큰으로 요청 테스트:")
try:
async with InstagramClient(access_token="INVALID_TOKEN") as client:
await client.get_media_list(limit=1)
print("[실패] 예외가 발생하지 않음")
except AuthenticationError as e:
print(f"[성공] AuthenticationError 발생: {e}")
except RateLimitError as e:
print(f"[성공] RateLimitError 발생: {e}")
if e.retry_after:
print(f" 재시도 대기 시간: {e.retry_after}")
except InstagramAPIError as e:
print(f"[성공] InstagramAPIError 발생: {e}")
print(f" 코드: {e.code}, 서브코드: {e.subcode}")
except Exception as e:
print(f"[성공] 예외 발생: {type(e).__name__}: {e}")
async def main():
"""모든 테스트 실행"""
print("\n" + "=" * 60)
print("Instagram Graph API POC 테스트")
print("=" * 60)
# 조회 테스트 (안전)
await test_get_media_list()
await test_get_media_detail()
# 게시 테스트 (기본 비활성화)
await test_publish_image()
await test_publish_video()
await test_publish_carousel()
# 에러 처리 테스트
await test_error_handling()
print("\n" + "=" * 60)
print("모든 테스트 완료")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

782
poc/instagram/manual.md Normal file
View File

@ -0,0 +1,782 @@
# InstagramClient 사용 매뉴얼
Instagram Graph API를 사용한 콘텐츠 게시 및 조회를 위한 비동기 클라이언트입니다.
---
## 목차
1. [개요](#개요)
2. [클래스 구조](#클래스-구조)
3. [초기화 및 설정](#초기화-및-설정)
4. [메서드 상세](#메서드-상세)
5. [예외 처리](#예외-처리)
6. [데이터 모델](#데이터-모델)
7. [사용 예제](#사용-예제)
8. [내부 동작 원리](#내부-동작-원리)
---
## 개요
### 주요 특징
- **비동기 지원**: `asyncio` 기반의 비동기 HTTP 클라이언트
- **멀티테넌트**: 각 사용자가 자신의 `access_token`으로 독립적인 인스턴스 생성
- **자동 재시도**: Rate Limit 및 서버 에러 시 지수 백오프 재시도
- **컨텍스트 매니저**: `async with` 패턴으로 리소스 자동 관리
- **타입 힌트**: 완전한 타입 힌트 지원
### 지원 기능
| 기능 | 메서드 | 설명 |
|------|--------|------|
| 미디어 목록 조회 | `get_media_list()` | 계정의 게시물 목록 조회 |
| 미디어 상세 조회 | `get_media()` | 특정 게시물 상세 정보 |
| 이미지 게시 | `publish_image()` | 단일 이미지 게시 |
| 비디오/릴스 게시 | `publish_video()` | 비디오 또는 릴스 게시 |
| 캐러셀 게시 | `publish_carousel()` | 2-10개 이미지 게시 |
---
## 클래스 구조
### 파일 구조
```
poc/instagram/
├── __init__.py # 패키지 초기화 및 export
├── client.py # InstagramClient 클래스
├── exceptions.py # 커스텀 예외 클래스
├── models.py # Pydantic 데이터 모델
├── main.py # 테스트 실행 파일
└── manual.md # 본 문서
```
### 클래스 다이어그램
```
InstagramClient
├── __init__(access_token, ...) # 초기화
├── __aenter__() # 컨텍스트 진입
├── __aexit__() # 컨텍스트 종료
├── get_media_list() # 미디어 목록 조회
├── get_media() # 미디어 상세 조회
├── publish_image() # 이미지 게시
├── publish_video() # 비디오 게시
├── publish_carousel() # 캐러셀 게시
├── _request() # (내부) HTTP 요청 처리
├── _wait_for_container() # (내부) 컨테이너 대기
├── _get_account_id() # (내부) 계정 ID 조회
├── _get_client() # (내부) HTTP 클라이언트 반환
└── _build_url() # (내부) URL 생성
```
---
## 초기화 및 설정
### 생성자 파라미터
```python
InstagramClient(
access_token: str, # (필수) Instagram 액세스 토큰
*,
base_url: str = None, # API 기본 URL (기본값: https://graph.instagram.com/v21.0)
timeout: float = 30.0, # HTTP 요청 타임아웃 (초)
max_retries: int = 3, # 최대 재시도 횟수
container_timeout: float = 300.0, # 컨테이너 처리 대기 타임아웃 (초)
container_poll_interval: float = 5.0, # 컨테이너 상태 확인 간격 (초)
)
```
### 파라미터 상세 설명
| 파라미터 | 타입 | 기본값 | 설명 |
|----------|------|--------|------|
| `access_token` | `str` | (필수) | Instagram Graph API 액세스 토큰 |
| `base_url` | `str` | `https://graph.instagram.com/v21.0` | API 엔드포인트 기본 URL |
| `timeout` | `float` | `30.0` | 개별 HTTP 요청 타임아웃 (초) |
| `max_retries` | `int` | `3` | Rate Limit/서버 에러 시 재시도 횟수 |
| `container_timeout` | `float` | `300.0` | 미디어 컨테이너 처리 대기 최대 시간 (초) |
| `container_poll_interval` | `float` | `5.0` | 컨테이너 상태 확인 폴링 간격 (초) |
### 기본 사용법
```python
from poc.instagram import InstagramClient
async with InstagramClient(access_token="YOUR_TOKEN") as client:
# API 호출
media_list = await client.get_media_list()
```
### 커스텀 설정 사용
```python
async with InstagramClient(
access_token="YOUR_TOKEN",
timeout=60.0, # 타임아웃 60초
max_retries=5, # 최대 5회 재시도
container_timeout=600.0, # 컨테이너 대기 10분
) as client:
# 대용량 비디오 업로드 등에 적합
await client.publish_video(video_url="...", caption="...")
```
---
## 메서드 상세
### get_media_list()
계정의 미디어 목록을 조회합니다.
```python
async def get_media_list(
self,
limit: int = 25, # 조회할 미디어 수 (최대 100)
after: Optional[str] = None # 페이지네이션 커서
) -> MediaList
```
**파라미터:**
| 파라미터 | 타입 | 기본값 | 설명 |
|----------|------|--------|------|
| `limit` | `int` | `25` | 조회할 미디어 수 (최대 100) |
| `after` | `str` | `None` | 다음 페이지 커서 (페이지네이션) |
**반환값:** `MediaList` - 미디어 목록
**예외:**
- `InstagramAPIError` - API 에러 발생 시
- `AuthenticationError` - 인증 실패 시
- `RateLimitError` - Rate Limit 초과 시
**사용 예제:**
```python
# 기본 조회
media_list = await client.get_media_list()
# 10개만 조회
media_list = await client.get_media_list(limit=10)
# 페이지네이션
media_list = await client.get_media_list(limit=25)
if media_list.next_cursor:
next_page = await client.get_media_list(limit=25, after=media_list.next_cursor)
```
---
### get_media()
특정 미디어의 상세 정보를 조회합니다.
```python
async def get_media(
self,
media_id: str # 미디어 ID
) -> Media
```
**파라미터:**
| 파라미터 | 타입 | 설명 |
|----------|------|------|
| `media_id` | `str` | 조회할 미디어 ID |
**반환값:** `Media` - 미디어 상세 정보
**조회되는 필드:**
- `id`, `media_type`, `media_url`, `thumbnail_url`
- `caption`, `timestamp`, `permalink`
- `like_count`, `comments_count`
- `children` (캐러셀인 경우 하위 미디어)
**사용 예제:**
```python
media = await client.get_media("17895695668004550")
print(f"타입: {media.media_type}")
print(f"좋아요: {media.like_count}")
print(f"링크: {media.permalink}")
```
---
### publish_image()
단일 이미지를 게시합니다.
```python
async def publish_image(
self,
image_url: str, # 이미지 URL (공개 접근 가능)
caption: Optional[str] = None # 게시물 캡션
) -> Media
```
**파라미터:**
| 파라미터 | 타입 | 설명 |
|----------|------|------|
| `image_url` | `str` | 공개 접근 가능한 이미지 URL (JPEG 권장) |
| `caption` | `str` | 게시물 캡션 (해시태그, 멘션 포함 가능) |
**반환값:** `Media` - 게시된 미디어 정보
**이미지 요구사항:**
- 형식: JPEG 권장
- 최소 크기: 320x320 픽셀
- 비율: 4:5 ~ 1.91:1
- URL: 공개 접근 가능 (인증 없이)
**사용 예제:**
```python
media = await client.publish_image(
image_url="https://cdn.example.com/photo.jpg",
caption="오늘의 사진 #photography #daily"
)
print(f"게시 완료: {media.permalink}")
```
---
### publish_video()
비디오 또는 릴스를 게시합니다.
```python
async def publish_video(
self,
video_url: str, # 비디오 URL (공개 접근 가능)
caption: Optional[str] = None, # 게시물 캡션
share_to_feed: bool = True # 피드 공유 여부
) -> Media
```
**파라미터:**
| 파라미터 | 타입 | 기본값 | 설명 |
|----------|------|--------|------|
| `video_url` | `str` | (필수) | 공개 접근 가능한 비디오 URL (MP4 권장) |
| `caption` | `str` | `None` | 게시물 캡션 |
| `share_to_feed` | `bool` | `True` | 피드에 공유 여부 |
**반환값:** `Media` - 게시된 미디어 정보
**비디오 요구사항:**
- 형식: MP4 (H.264 코덱)
- 길이: 3초 ~ 60분 (릴스)
- 해상도: 최소 720p
- 비율: 9:16 (세로), 16:9 (가로), 1:1 (정사각형)
**참고:**
- 비디오 처리 시간이 이미지보다 오래 걸립니다
- 내부적으로 `container_timeout * 2` 시간까지 대기합니다
**사용 예제:**
```python
media = await client.publish_video(
video_url="https://cdn.example.com/video.mp4",
caption="새로운 릴스! #reels #trending",
share_to_feed=True
)
print(f"게시 완료: {media.permalink}")
```
---
### publish_carousel()
캐러셀(멀티 이미지)을 게시합니다.
```python
async def publish_carousel(
self,
media_urls: list[str], # 이미지 URL 목록 (2-10개)
caption: Optional[str] = None # 게시물 캡션
) -> Media
```
**파라미터:**
| 파라미터 | 타입 | 설명 |
|----------|------|------|
| `media_urls` | `list[str]` | 이미지 URL 목록 (2-10개 필수) |
| `caption` | `str` | 게시물 캡션 |
**반환값:** `Media` - 게시된 미디어 정보
**예외:**
- `ValueError` - 이미지 수가 2-10개가 아닌 경우
**특징:**
- 각 이미지의 컨테이너가 **병렬로** 생성됩니다 (성능 최적화)
- 모든 이미지가 동일한 요구사항을 충족해야 합니다
**사용 예제:**
```python
media = await client.publish_carousel(
media_urls=[
"https://cdn.example.com/img1.jpg",
"https://cdn.example.com/img2.jpg",
"https://cdn.example.com/img3.jpg",
],
caption="여행 사진 모음 #travel #photos"
)
print(f"게시 완료: {media.permalink}")
```
---
## 예외 처리
### 예외 계층 구조
```
Exception
└── InstagramAPIError # 기본 예외
├── AuthenticationError # 인증 오류 (code=190)
├── RateLimitError # Rate Limit (code=4, 17, 341)
├── ContainerStatusError # 컨테이너 ERROR 상태
└── ContainerTimeoutError # 컨테이너 타임아웃
```
### 예외 클래스 상세
#### InstagramAPIError
모든 Instagram API 예외의 기본 클래스입니다.
```python
class InstagramAPIError(Exception):
message: str # 에러 메시지
code: Optional[int] # API 에러 코드
subcode: Optional[int] # API 서브코드
fbtrace_id: Optional[str] # Facebook 트레이스 ID (디버깅용)
```
#### AuthenticationError
인증 관련 에러입니다.
- 토큰 만료
- 유효하지 않은 토큰
- 앱 권한 부족
```python
try:
await client.get_media_list()
except AuthenticationError as e:
print(f"인증 실패: {e.message}")
print(f"에러 코드: {e.code}") # 보통 190
```
#### RateLimitError
API 호출 제한 초과 에러입니다.
```python
class RateLimitError(InstagramAPIError):
retry_after: Optional[int] # 재시도까지 대기 시간 (초)
```
```python
try:
await client.get_media_list()
except RateLimitError as e:
print(f"Rate Limit 초과: {e.message}")
if e.retry_after:
print(f"{e.retry_after}초 후 재시도")
await asyncio.sleep(e.retry_after)
```
#### ContainerStatusError
미디어 컨테이너가 ERROR 상태가 된 경우 발생합니다.
- 잘못된 미디어 형식
- 지원하지 않는 코덱
- 미디어 URL 접근 불가
#### ContainerTimeoutError
컨테이너가 지정된 시간 내에 처리되지 않은 경우 발생합니다.
```python
try:
await client.publish_video(video_url="...", caption="...")
except ContainerTimeoutError as e:
print(f"타임아웃: {e}")
```
### 에러 코드 매핑
| 에러 코드 | 예외 클래스 | 설명 |
|-----------|-------------|------|
| 4 | `RateLimitError` | API 호출 제한 |
| 17 | `RateLimitError` | 사용자별 호출 제한 |
| 190 | `AuthenticationError` | 인증 실패 |
| 341 | `RateLimitError` | 앱 호출 제한 |
### 종합 예외 처리 예제
```python
from poc.instagram import (
InstagramClient,
AuthenticationError,
RateLimitError,
ContainerStatusError,
ContainerTimeoutError,
InstagramAPIError,
)
async with InstagramClient(access_token="YOUR_TOKEN") as client:
try:
media = await client.publish_image(
image_url="https://example.com/image.jpg",
caption="테스트"
)
print(f"성공: {media.permalink}")
except AuthenticationError as e:
print(f"인증 오류: {e}")
# 토큰 갱신 로직 실행
except RateLimitError as e:
print(f"Rate Limit: {e}")
if e.retry_after:
await asyncio.sleep(e.retry_after)
# 재시도
except ContainerStatusError as e:
print(f"미디어 처리 실패: {e}")
# 미디어 형식 확인
except ContainerTimeoutError as e:
print(f"처리 시간 초과: {e}")
# 더 긴 타임아웃으로 재시도
except InstagramAPIError as e:
print(f"API 에러: {e}")
print(f"코드: {e.code}, 서브코드: {e.subcode}")
except Exception as e:
print(f"예상치 못한 에러: {e}")
```
---
## 데이터 모델
### Media
미디어 정보를 담는 Pydantic 모델입니다.
```python
class Media(BaseModel):
id: str # 미디어 ID
media_type: Optional[str] # IMAGE, VIDEO, CAROUSEL_ALBUM
media_url: Optional[str] # 미디어 URL
thumbnail_url: Optional[str] # 썸네일 URL (비디오)
caption: Optional[str] # 캡션
timestamp: Optional[datetime] # 게시 시간
permalink: Optional[str] # 퍼머링크
like_count: int = 0 # 좋아요 수
comments_count: int = 0 # 댓글 수
children: Optional[list[Media]] # 캐러셀 하위 미디어
```
### MediaList
미디어 목록 응답 모델입니다.
```python
class MediaList(BaseModel):
data: list[Media] # 미디어 목록
paging: Optional[dict[str, Any]] # 페이지네이션 정보
@property
def next_cursor(self) -> Optional[str]:
"""다음 페이지 커서"""
```
### MediaContainer
미디어 컨테이너 상태 모델입니다.
```python
class MediaContainer(BaseModel):
id: str # 컨테이너 ID
status_code: Optional[str] # IN_PROGRESS, FINISHED, ERROR
status: Optional[str] # 상태 메시지
@property
def is_finished(self) -> bool: ...
@property
def is_error(self) -> bool: ...
@property
def is_in_progress(self) -> bool: ...
```
---
## 사용 예제
### 미디어 목록 조회 및 출력
```python
import asyncio
from poc.instagram import InstagramClient
async def main():
async with InstagramClient(access_token="YOUR_TOKEN") as client:
media_list = await client.get_media_list(limit=10)
for media in media_list.data:
print(f"[{media.media_type}] {media.caption[:30] if media.caption else '(캡션 없음)'}")
print(f" 좋아요: {media.like_count:,} | 댓글: {media.comments_count:,}")
print(f" 링크: {media.permalink}")
print()
asyncio.run(main())
```
### 이미지 게시
```python
async def post_image():
async with InstagramClient(access_token="YOUR_TOKEN") as client:
media = await client.publish_image(
image_url="https://cdn.example.com/photo.jpg",
caption="오늘의 사진 #photography"
)
return media.permalink
permalink = asyncio.run(post_image())
print(f"게시됨: {permalink}")
```
### 멀티테넌트 병렬 게시
여러 사용자가 동시에 게시물을 올리는 예제입니다.
```python
import asyncio
from poc.instagram import InstagramClient
async def post_for_user(user_id: str, token: str, image_url: str, caption: str):
"""특정 사용자의 계정에 게시"""
async with InstagramClient(access_token=token) as client:
media = await client.publish_image(image_url=image_url, caption=caption)
return {"user_id": user_id, "permalink": media.permalink}
async def main():
users = [
{"user_id": "user1", "token": "TOKEN1", "image": "https://...", "caption": "User1 post"},
{"user_id": "user2", "token": "TOKEN2", "image": "https://...", "caption": "User2 post"},
{"user_id": "user3", "token": "TOKEN3", "image": "https://...", "caption": "User3 post"},
]
# 병렬 실행
tasks = [
post_for_user(u["user_id"], u["token"], u["image"], u["caption"])
for u in users
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
print(f"실패: {result}")
else:
print(f"성공: {result['user_id']} -> {result['permalink']}")
asyncio.run(main())
```
### 페이지네이션으로 전체 미디어 조회
```python
async def get_all_media(client: InstagramClient, max_items: int = 100):
"""전체 미디어 조회 (페이지네이션)"""
all_media = []
cursor = None
while len(all_media) < max_items:
media_list = await client.get_media_list(limit=25, after=cursor)
all_media.extend(media_list.data)
if not media_list.next_cursor:
break
cursor = media_list.next_cursor
return all_media[:max_items]
```
---
## 내부 동작 원리
### HTTP 클라이언트 생명주기
```
async with InstagramClient(...) as client:
├── __aenter__()
│ └── httpx.AsyncClient 생성
├── API 호출들...
│ └── 동일한 HTTP 클라이언트 재사용 (연결 풀링)
└── __aexit__()
└── httpx.AsyncClient.aclose()
```
### 미디어 게시 프로세스
Instagram API의 미디어 게시는 3단계로 진행됩니다:
```
┌─────────────────────────────────────────────────────────┐
│ 미디어 게시 프로세스 │
├─────────────────────────────────────────────────────────┤
│ │
│ Step 1: Container 생성 │
│ POST /{account_id}/media │
│ ├── image_url / video_url 전달 │
│ └── Container ID 반환 │
│ │
│ Step 2: Container 상태 대기 (폴링) │
│ GET /{container_id}?fields=status_code │
│ ├── IN_PROGRESS: 계속 대기 │
│ ├── FINISHED: 다음 단계로 │
│ └── ERROR: ContainerStatusError 발생 │
│ │
│ Step 3: 게시 │
│ POST /{account_id}/media_publish │
│ └── Media ID 반환 │
│ │
└─────────────────────────────────────────────────────────┘
```
### 캐러셀 게시 프로세스
```
┌─────────────────────────────────────────────────────────┐
│ 캐러셀 게시 프로세스 │
├─────────────────────────────────────────────────────────┤
│ │
│ Step 1: 각 이미지 Container 병렬 생성 │
│ ├── asyncio.gather()로 동시 실행 │
│ └── children_ids = [id1, id2, id3, ...] │
│ │
│ Step 2: 캐러셀 Container 생성 │
│ POST /{account_id}/media │
│ ├── media_type: "CAROUSEL" │
│ └── children: "id1,id2,id3" │
│ │
│ Step 3: Container 상태 대기 │
│ │
│ Step 4: 게시 │
│ │
└─────────────────────────────────────────────────────────┘
```
### 자동 재시도 로직
```python
retry_base_delay = 1.0
for attempt in range(max_retries + 1):
try:
response = await client.request(...)
if response.status_code == 429: # Rate Limit
wait_time = max(retry_base_delay * (2 ** attempt), retry_after)
await asyncio.sleep(wait_time)
continue
if response.status_code >= 500: # 서버 에러
wait_time = retry_base_delay * (2 ** attempt)
await asyncio.sleep(wait_time)
continue
return response.json()
except httpx.HTTPError:
wait_time = retry_base_delay * (2 ** attempt)
await asyncio.sleep(wait_time)
continue
```
### 계정 ID 캐싱
계정 ID는 첫 조회 후 캐시됩니다:
```python
async def _get_account_id(self) -> str:
if self._account_id:
return self._account_id # 캐시 반환
async with self._account_id_lock: # 동시성 안전
if self._account_id:
return self._account_id
response = await self._request("GET", "me", {"fields": "id"})
self._account_id = response["id"]
return self._account_id
```
---
## API 제한사항
### Rate Limits
| 제한 | 값 | 설명 |
|------|-----|------|
| 시간당 요청 | 200회 | 사용자 토큰당 |
| 일일 게시 | 25개 | 계정당 (공식 문서 확인 필요) |
### 미디어 요구사항
**이미지:**
- 형식: JPEG 권장
- 최소 크기: 320x320 픽셀
- 비율: 4:5 ~ 1.91:1
**비디오:**
- 형식: MP4 (H.264)
- 길이: 3초 ~ 60분 (릴스)
- 해상도: 최소 720p
- 비율: 9:16 (세로), 16:9 (가로), 1:1 (정사각형)
**캐러셀:**
- 이미지 수: 2-10개
- 각 이미지는 위 요구사항 충족 필요
### URL 요구사항
게시할 미디어 URL은:
- HTTPS 프로토콜 권장
- 공개적으로 접근 가능 (인증 없이)
- CDN 또는 S3 등의 공개 URL 사용
---
## 참고 문서
- [Instagram Graph API 공식 문서](https://developers.facebook.com/docs/instagram-platform)
- [Content Publishing API](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing)
- [Graph API Explorer](https://developers.facebook.com/tools/explorer/)

View File

@ -1,497 +1,75 @@
"""
Instagram Graph API Pydantic 모델 모듈
Instagram Graph API Pydantic 모델
API 요청/응답에 사용되는 데이터 모델을 정의합니다.
API 응답 데이터를 위한 Pydantic 모델 정의입니다.
"""
from datetime import datetime, timezone
from enum import Enum
from datetime import datetime
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):
"""
미디어 정보
"""Instagram 미디어 정보"""
이미지, 비디오, 캐러셀, 릴스 등의 미디어 정보를 담습니다.
"""
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
id: str
media_type: Optional[str] = None
media_url: Optional[str] = None
thumbnail_url: Optional[str] = None
caption: Optional[str] = None
timestamp: Optional[datetime] = None
permalink: Optional[str] = None
like_count: int = 0
comments_count: int = 0
children: Optional[list["Media"]] = None
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",
)
data: list[Media] = Field(default_factory=list)
paging: Optional[dict[str, Any]] = None
@property
def latest_value(self) -> Any:
"""최신 값 반환"""
if self.values:
return self.values[-1].value
def next_cursor(self) -> Optional[str]:
"""다음 페이지 커서"""
if self.paging and "cursors" in self.paging:
return self.paging["cursors"].get("after")
return None
class InsightResponse(BaseModel):
"""인사이트 응답"""
class MediaContainer(BaseModel):
"""미디어 컨테이너 상태"""
data: list[Insight] = Field(
default_factory=list,
description="인사이트 목록",
)
id: str
status_code: Optional[str] = None
status: Optional[str] = None
def get_metric(self, name: str) -> Optional[Insight]:
"""메트릭 이름으로 인사이트 조회"""
for insight in self.data:
if insight.name == name:
return insight
return None
@property
def is_finished(self) -> bool:
return self.status_code == "FINISHED"
@property
def is_error(self) -> bool:
return self.status_code == "ERROR"
# ==========================================================================
# 댓글 모델
# ==========================================================================
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="페이징 정보",
)
# ==========================================================================
# 에러 응답 모델
# ==========================================================================
@property
def is_in_progress(self) -> bool:
return self.status_code == "IN_PROGRESS"
class APIError(BaseModel):
"""
Instagram API 에러 응답
"""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",
)
message: str
type: Optional[str] = None
code: Optional[int] = None
error_subcode: Optional[int] = None
fbtrace_id: Optional[str] = None
class ErrorResponse(BaseModel):
"""에러 응답 래퍼"""
error: APIError
# ==========================================================================
# 모델 업데이트 (순환 참조 해결)
# ==========================================================================
# Pydantic v2에서 순환 참조를 위한 모델 재빌드
Media.model_rebuild()
Comment.model_rebuild()
CommentList.model_rebuild()

266
poc/instagram/poc.md Normal file
View File

@ -0,0 +1,266 @@
# Instagram Graph API POC
Instagram Graph API를 사용한 콘텐츠 게시 및 조회 클라이언트입니다.
## 개요
이 POC는 Instagram Graph API의 Content Publishing 기능을 테스트합니다.
### 지원 기능
| 기능 | 설명 | 메서드 |
|------|------|--------|
| 미디어 목록 조회 | 계정의 게시물 목록 조회 | `get_media_list()` |
| 미디어 상세 조회 | 특정 게시물 상세 정보 | `get_media()` |
| 이미지 게시 | 단일 이미지 게시 | `publish_image()` |
| 비디오/릴스 게시 | 비디오 또는 릴스 게시 | `publish_video()` |
| 캐러셀 게시 | 2-10개 이미지 게시 | `publish_carousel()` |
## 동작 원리
### 1. 인증 흐름
```
[사용자] → [Instagram 앱] → [Access Token 발급]
[InstagramClient(access_token=...)] ← 토큰 전달
```
Instagram Graph API는 OAuth 2.0 기반입니다:
1. Meta for Developers에서 앱 생성
2. Instagram Graph API 제품 추가
3. 사용자 인증 후 Access Token 발급
4. Token을 `InstagramClient`에 전달
### 2. 미디어 게시 프로세스
Instagram 미디어 게시는 3단계로 진행됩니다:
```
┌─────────────────────────────────────────────────────────────┐
│ 미디어 게시 프로세스 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Step 1: Container 생성 │
│ POST /{account_id}/media │
│ → Container ID 반환 │
│ │
│ Step 2: Container 상태 대기 │
│ GET /{container_id}?fields=status_code │
│ → IN_PROGRESS → FINISHED (폴링) │
│ │
│ Step 3: 게시 │
│ POST /{account_id}/media_publish │
│ → Media ID 반환 │
│ │
└─────────────────────────────────────────────────────────────┘
```
**캐러셀의 경우:**
1. 각 이미지마다 개별 Container 생성 (병렬 처리)
2. 캐러셀 Container 생성 (children ID 목록 전달)
3. 캐러셀 Container 상태 대기
4. 게시
### 3. HTTP 클라이언트 재사용
`InstagramClient``async with` 블록 내에서 HTTP 연결을 재사용합니다:
```python
async with InstagramClient(access_token="...") as client:
# 이 블록 내의 모든 API 호출은 동일한 HTTP 클라이언트 사용
await client.get_media_list() # 연결 1
await client.publish_image(...) # 연결 재사용 (4+ 요청)
await client.get_media(...) # 연결 재사용
```
## 환경 설정
### 1. 필수 환경변수
```bash
# Instagram Access Token (필수)
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
```
### 2. 의존성 설치
```bash
uv add httpx pydantic
```
### 3. Access Token 발급 방법
1. [Meta for Developers](https://developers.facebook.com/)에서 앱 생성
2. Instagram Graph API 제품 추가
3. 권한 설정:
- `instagram_basic` - 기본 프로필 정보
- `instagram_content_publish` - 콘텐츠 게시
4. Graph API Explorer에서 토큰 발급
## 사용 예제
### 기본 사용법
```python
import asyncio
from poc.instagram.client import InstagramClient
async def main():
async with InstagramClient(access_token="YOUR_TOKEN") as client:
# 미디어 목록 조회
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 InstagramClient(access_token="YOUR_TOKEN") as client:
media = await client.publish_image(
image_url="https://example.com/photo.jpg",
caption="My photo! #photography"
)
print(f"게시 완료: {media.permalink}")
```
### 비디오/릴스 게시
```python
async with InstagramClient(access_token="YOUR_TOKEN") as client:
media = await client.publish_video(
video_url="https://example.com/video.mp4",
caption="Check this out! #video",
share_to_feed=True
)
print(f"게시 완료: {media.permalink}")
```
### 캐러셀 게시
```python
async with InstagramClient(access_token="YOUR_TOKEN") as client:
media = await client.publish_carousel(
media_urls=[
"https://example.com/img1.jpg",
"https://example.com/img2.jpg",
"https://example.com/img3.jpg",
],
caption="My carousel! #photos"
)
print(f"게시 완료: {media.permalink}")
```
### 에러 처리
```python
import httpx
from poc.instagram.client import InstagramClient
async with InstagramClient(access_token="YOUR_TOKEN") as client:
try:
media = await client.publish_image(...)
except httpx.HTTPStatusError as e:
print(f"API 오류: {e}")
print(f"상태 코드: {e.response.status_code}")
except TimeoutError as e:
print(f"타임아웃: {e}")
except RuntimeError as e:
print(f"컨테이너 처리 실패: {e}")
except Exception as e:
print(f"예상치 못한 오류: {e}")
```
### 멀티테넌트 사용
여러 사용자가 각자의 토큰으로 독립적인 인스턴스를 사용합니다:
```python
async def post_for_user(user_token: str, image_url: str, caption: str):
async with InstagramClient(access_token=user_token) as client:
return await client.publish_image(image_url=image_url, caption=caption)
# 여러 사용자에 대해 병렬 실행
results = await asyncio.gather(
post_for_user("USER1_TOKEN", "https://...", "User 1 post"),
post_for_user("USER2_TOKEN", "https://...", "User 2 post"),
post_for_user("USER3_TOKEN", "https://...", "User 3 post"),
)
```
## API 제한사항
### Rate Limits
| 제한 | 값 | 설명 |
|------|-----|------|
| 시간당 요청 | 200회 | 사용자 토큰당 |
| 일일 게시 | 25개 | 계정당 (공식 문서 확인 필요) |
Rate limit 초과 시 `RateLimitError`가 발생하며, `retry_after` 속성으로 대기 시간을 확인할 수 있습니다.
### 미디어 요구사항
**이미지:**
- 형식: JPEG 권장
- 최소 크기: 320x320 픽셀
- 비율: 4:5 ~ 1.91:1
**비디오:**
- 형식: MP4 (H.264)
- 길이: 3초 ~ 60분 (릴스)
- 해상도: 최소 720p
- 비율: 9:16 (세로), 16:9 (가로), 1:1 (정사각형)
**캐러셀:**
- 이미지 수: 2-10개
- 각 이미지는 위 이미지 요구사항 충족 필요
### 미디어 URL 요구사항
게시할 미디어는 **공개적으로 접근 가능한 URL**이어야 합니다:
- HTTPS 프로토콜 권장
- 인증 없이 접근 가능해야 함
- CDN 또는 S3 등의 공개 URL 사용
## 예외 처리
표준 Python 및 httpx 예외를 사용합니다:
| 예외 | 설명 | 원인 |
|------|------|------|
| `httpx.HTTPStatusError` | HTTP 상태 에러 | API 에러 응답 (4xx, 5xx) |
| `httpx.HTTPError` | HTTP 통신 에러 | 네트워크 오류, 재시도 초과 |
| `TimeoutError` | 타임아웃 | 컨테이너 처리 시간 초과 |
| `RuntimeError` | 런타임 에러 | 컨테이너 처리 실패, 컨텍스트 매니저 미사용 |
| `ValueError` | 값 에러 | 잘못된 파라미터 (토큰 누락, 캐러셀 이미지 수 등) |
## 테스트 실행
```bash
# 환경변수 설정
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
# 테스트 실행
python -m poc.instagram.main
```
## 파일 구조
```
poc/instagram/
├── __init__.py # 패키지 초기화 및 export
├── client.py # InstagramClient 클래스
├── models.py # Pydantic 모델 (Media, MediaList 등)
├── main.py # 테스트 실행 파일
└── poc.md # 사용 매뉴얼 (본 문서)
```
## 참고 문서
- [Instagram Graph API 공식 문서](https://developers.facebook.com/docs/instagram-platform)
- [Content Publishing API](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing)
- [Graph API Explorer](https://developers.facebook.com/tools/explorer/)

View File

@ -316,7 +316,7 @@ async def _request(...) -> dict[str, Any]:
```python
# 테스트 예시
import pytest
from poc.instagram.config import get_settings
from poc.instagram1.config import get_settings
@pytest.fixture
def mock_settings(monkeypatch):

View File

@ -56,7 +56,7 @@ export INSTAGRAM_APP_SECRET="your_app_secret"
```python
import asyncio
from poc.instagram import InstagramGraphClient
from poc.instagram1 import InstagramGraphClient
async def main():
async with InstagramGraphClient() as client:
@ -130,29 +130,29 @@ async with InstagramGraphClient() as client:
```bash
# 인증 예제
python -m poc.instagram.examples.auth_example
python -m poc.instagram1.examples.auth_example
# 계정 예제
python -m poc.instagram.examples.account_example
python -m poc.instagram1.examples.account_example
# 미디어 예제
python -m poc.instagram.examples.media_example
python -m poc.instagram1.examples.media_example
# 인사이트 예제
python -m poc.instagram.examples.insights_example
python -m poc.instagram1.examples.insights_example
# 댓글 예제
python -m poc.instagram.examples.comments_example
python -m poc.instagram1.examples.comments_example
```
## 에러 처리
```python
from poc.instagram import (
from poc.instagram1 import (
InstagramGraphClient,
AuthenticationError,
RateLimitError,
PermissionError,
InstagramPermissionError,
MediaPublishError,
)
@ -163,7 +163,7 @@ async with InstagramGraphClient() as client:
print(f"토큰 오류: {e}")
except RateLimitError as e:
print(f"Rate limit 초과. {e.retry_after}초 후 재시도")
except PermissionError as e:
except InstagramPermissionError as e:
print(f"권한 부족: {e}")
```
@ -176,7 +176,7 @@ async with InstagramGraphClient() as client:
## 파일 구조
```
poc/instagram/
poc/instagram1/
├── __init__.py # 패키지 진입점
├── config.py # 설정 (환경변수)
├── exceptions.py # 커스텀 예외

View File

@ -0,0 +1,101 @@
"""
Instagram Graph API POC 패키지
Instagram Graph API와의 통신을 위한 비동기 클라이언트를 제공합니다.
Example:
```python
from poc.instagram1 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

View File

@ -4,6 +4,7 @@ Instagram Graph API 설정 모듈
환경변수를 통해 Instagram API 연동에 필요한 설정을 관리합니다.
"""
from functools import lru_cache
from typing import Optional
from pydantic import Field
@ -118,9 +119,6 @@ class InstagramSettings(BaseSettings):
return f"{self.facebook_base_url}/{self.api_version}/{endpoint}"
from functools import lru_cache
@lru_cache()
def get_settings() -> InstagramSettings:
"""

View File

@ -6,7 +6,7 @@ Instagram Graph API 계정 정보 조회 예제
실행 방법:
```bash
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
python -m poc.instagram.examples.account_example
python -m poc.instagram1.examples.account_example
```
"""
@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
async def example_get_account():
"""계정 정보 조회 예제"""
from poc.instagram import InstagramGraphClient, AuthenticationError
from poc.instagram1 import InstagramGraphClient, AuthenticationError
print("\n" + "=" * 60)
print("계정 정보 조회")
@ -65,7 +65,7 @@ async def example_get_account():
async def example_get_account_id():
"""계정 ID만 조회 예제"""
from poc.instagram import InstagramGraphClient
from poc.instagram1 import InstagramGraphClient
print("\n" + "=" * 60)
print("계정 ID 조회 (캐시 테스트)")

View File

@ -12,14 +12,14 @@ Instagram Graph API 인증 예제
# 실행
cd /path/to/project
python -m poc.instagram.examples.auth_example
python -m poc.instagram1.examples.auth_example
```
"""
import asyncio
import logging
import sys
from datetime import datetime
from datetime import datetime, timezone
# 로깅 설정
logging.basicConfig(
@ -32,7 +32,7 @@ logger = logging.getLogger(__name__)
async def example_debug_token():
"""토큰 검증 예제"""
from poc.instagram import InstagramGraphClient, AuthenticationError
from poc.instagram1 import InstagramGraphClient, AuthenticationError
print("\n" + "=" * 60)
print("1. 토큰 검증 (Debug Token)")
@ -50,7 +50,7 @@ async def example_debug_token():
print(f"⏰ 만료 시각: {data.expires_at_datetime}")
# 만료까지 남은 시간 계산
remaining = data.expires_at_datetime - datetime.now()
remaining = data.expires_at_datetime - datetime.now(timezone.utc)
print(f"⏳ 남은 시간: {remaining.days}{remaining.seconds // 3600}시간")
if data.is_expired:
@ -67,7 +67,7 @@ async def example_debug_token():
async def example_exchange_token():
"""토큰 교환 예제 (단기 → 장기)"""
from poc.instagram import InstagramGraphClient, AuthenticationError
from poc.instagram1 import InstagramGraphClient, AuthenticationError
print("\n" + "=" * 60)
print("2. 토큰 교환 (Short-lived → Long-lived)")
@ -96,7 +96,7 @@ async def example_exchange_token():
async def example_refresh_token():
"""토큰 갱신 예제"""
from poc.instagram import InstagramGraphClient, AuthenticationError
from poc.instagram1 import InstagramGraphClient, AuthenticationError
print("\n" + "=" * 60)
print("3. 토큰 갱신 (Long-lived Token Refresh)")

View File

@ -6,7 +6,7 @@ Instagram Graph API 댓글 관리 예제
실행 방법:
```bash
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
python -m poc.instagram.examples.comments_example
python -m poc.instagram1.examples.comments_example
```
"""
@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
async def example_get_comments():
"""댓글 목록 조회 예제"""
from poc.instagram import InstagramGraphClient
from poc.instagram1 import InstagramGraphClient
print("\n" + "=" * 60)
print("1. 댓글 목록 조회")
@ -84,7 +84,7 @@ async def example_get_comments():
async def example_find_comments_to_reply():
"""답글이 필요한 댓글 찾기"""
from poc.instagram import InstagramGraphClient
from poc.instagram1 import InstagramGraphClient
print("\n" + "=" * 60)
print("2. 답글이 필요한 댓글 찾기")
@ -133,7 +133,7 @@ async def example_find_comments_to_reply():
async def example_reply_comment():
"""댓글에 답글 작성 예제 (테스트용 - 실제 게시됨)"""
from poc.instagram import InstagramGraphClient
from poc.instagram1 import InstagramGraphClient
print("\n" + "=" * 60)
print("3. 댓글 답글 작성 (테스트)")
@ -180,7 +180,7 @@ async def example_reply_comment():
async def example_comment_analytics():
"""댓글 분석 예제"""
from poc.instagram import InstagramGraphClient
from poc.instagram1 import InstagramGraphClient
print("\n" + "=" * 60)
print("4. 댓글 분석")

View File

@ -6,7 +6,7 @@ Instagram Graph API 인사이트 조회 예제
실행 방법:
```bash
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
python -m poc.instagram.examples.insights_example
python -m poc.instagram1.examples.insights_example
```
"""
@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
async def example_account_insights():
"""계정 인사이트 조회 예제"""
from poc.instagram import InstagramGraphClient, PermissionError
from poc.instagram1 import InstagramGraphClient, InstagramPermissionError
print("\n" + "=" * 60)
print("1. 계정 인사이트 조회")
@ -57,7 +57,7 @@ async def example_account_insights():
if reach:
print(f"\n✅ 도달(reach) 직접 조회: {reach.latest_value:,}")
except PermissionError as e:
except InstagramPermissionError as e:
print(f"❌ 권한 에러: {e}")
print(" 비즈니스 계정이 필요하거나, 인사이트 권한이 없습니다.")
except Exception as e:
@ -66,7 +66,7 @@ async def example_account_insights():
async def example_account_insights_periods():
"""다양한 기간의 계정 인사이트 조회"""
from poc.instagram import InstagramGraphClient
from poc.instagram1 import InstagramGraphClient
print("\n" + "=" * 60)
print("2. 기간별 계정 인사이트 비교")
@ -100,7 +100,7 @@ async def example_account_insights_periods():
async def example_media_insights():
"""미디어 인사이트 조회 예제"""
from poc.instagram import InstagramGraphClient, PermissionError
from poc.instagram1 import InstagramGraphClient, InstagramPermissionError
print("\n" + "=" * 60)
print("3. 미디어 인사이트 조회")
@ -132,7 +132,7 @@ async def example_media_insights():
value = insight.latest_value
print(f" 📈 {insight.name}: {value:,}" if isinstance(value, int) else f" 📈 {insight.name}: {value}")
except PermissionError as e:
except InstagramPermissionError as e:
print(f" ⚠️ 권한 부족: 인사이트 조회 불가")
except Exception as e:
print(f" ⚠️ 조회 실패: {e}")
@ -143,7 +143,7 @@ async def example_media_insights():
async def example_media_insights_detail():
"""미디어 인사이트 상세 조회"""
from poc.instagram import InstagramGraphClient
from poc.instagram1 import InstagramGraphClient
print("\n" + "=" * 60)
print("4. 미디어 인사이트 상세 분석")

View File

@ -6,7 +6,7 @@ Instagram Graph API 미디어 관리 예제
실행 방법:
```bash
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
python -m poc.instagram.examples.media_example
python -m poc.instagram1.examples.media_example
```
"""
@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
async def example_get_media_list():
"""미디어 목록 조회 예제"""
from poc.instagram import InstagramGraphClient
from poc.instagram1 import InstagramGraphClient
print("\n" + "=" * 60)
print("1. 미디어 목록 조회")
@ -61,7 +61,7 @@ async def example_get_media_list():
async def example_get_media_detail():
"""미디어 상세 조회 예제"""
from poc.instagram import InstagramGraphClient
from poc.instagram1 import InstagramGraphClient
print("\n" + "=" * 60)
print("2. 미디어 상세 조회")
@ -107,7 +107,7 @@ async def example_get_media_detail():
async def example_publish_image():
"""이미지 게시 예제 (테스트용 - 실제 게시됨)"""
from poc.instagram import InstagramGraphClient, MediaPublishError
from poc.instagram1 import InstagramGraphClient, MediaPublishError
print("\n" + "=" * 60)
print("3. 이미지 게시 (테스트)")
@ -140,7 +140,7 @@ async def example_publish_image():
async def example_publish_video():
"""비디오 게시 예제 (테스트용 - 실제 게시됨)"""
from poc.instagram import InstagramGraphClient, MediaPublishError
from poc.instagram1 import InstagramGraphClient, MediaPublishError
print("\n" + "=" * 60)
print("4. 비디오/릴스 게시 (테스트)")
@ -173,7 +173,7 @@ async def example_publish_video():
async def example_publish_carousel():
"""캐러셀 게시 예제 (테스트용 - 실제 게시됨)"""
from poc.instagram import InstagramGraphClient, MediaPublishError
from poc.instagram1 import InstagramGraphClient, MediaPublishError
print("\n" + "=" * 60)
print("5. 캐러셀(멀티 이미지) 게시 (테스트)")

View File

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

View File

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

View File

@ -0,0 +1,51 @@
"""
Instagram Graph API POC 패키지
단일 클래스로 구현된 Instagram Graph API 클라이언트입니다.
Example:
```python
from poc.instagram import InstagramClient
async with InstagramClient(access_token="YOUR_TOKEN") as client:
media = await client.publish_image(
image_url="https://example.com/image.jpg",
caption="Hello!"
)
```
"""
from poc.instagram.client import InstagramClient
from poc.instagram.exceptions import (
InstagramAPIError,
AuthenticationError,
RateLimitError,
ContainerStatusError,
ContainerTimeoutError,
)
from poc.instagram.models import (
Media,
MediaList,
MediaContainer,
APIError,
ErrorResponse,
)
__all__ = [
# Client
"InstagramClient",
# Exceptions
"InstagramAPIError",
"AuthenticationError",
"RateLimitError",
"ContainerStatusError",
"ContainerTimeoutError",
# Models
"Media",
"MediaList",
"MediaContainer",
"APIError",
"ErrorResponse",
]
__version__ = "0.1.0"

View File

@ -0,0 +1,504 @@
"""
Instagram Graph API Client
Instagram Graph API를 사용한 콘텐츠 게시 조회를 위한 비동기 클라이언트입니다.
멀티테넌트 지원 - 사용자가 자신의 access_token으로 인스턴스를 생성합니다.
Example:
```python
async with InstagramClient(access_token="YOUR_TOKEN") as client:
media = await client.publish_image(
image_url="https://example.com/image.jpg",
caption="Hello Instagram!"
)
print(f"게시 완료: {media.permalink}")
```
"""
import asyncio
import logging
import time
from typing import Any, Optional
import httpx
from .exceptions import (
ContainerStatusError,
ContainerTimeoutError,
InstagramAPIError,
RateLimitError,
create_exception_from_error,
)
from .models import ErrorResponse, Media, MediaContainer, MediaList
logger = logging.getLogger(__name__)
class InstagramClient:
"""
Instagram Graph API 비동기 클라이언트
멀티테넌트 지원 - 사용자가 자신의 access_token으로 인스턴스를 생성합니다.
비동기 컨텍스트 매니저로 사용해야 합니다.
Example:
```python
async with InstagramClient(access_token="USER_TOKEN") as client:
media = await client.publish_image(
image_url="https://example.com/image.jpg",
caption="My photo!"
)
print(f"게시됨: {media.permalink}")
```
"""
DEFAULT_BASE_URL = "https://graph.instagram.com/v21.0"
def __init__(
self,
access_token: str,
*,
base_url: Optional[str] = None,
timeout: float = 30.0,
max_retries: int = 3,
container_timeout: float = 300.0,
container_poll_interval: float = 5.0,
):
"""
클라이언트 초기화
Args:
access_token: Instagram 액세스 토큰 (필수)
base_url: API 기본 URL (기본값: https://graph.instagram.com/v21.0)
timeout: HTTP 요청 타임아웃 ()
max_retries: 최대 재시도 횟수
container_timeout: 컨테이너 처리 대기 타임아웃 ()
container_poll_interval: 컨테이너 상태 확인 간격 ()
"""
if not access_token:
raise ValueError("access_token은 필수입니다.")
self.access_token = access_token
self.base_url = base_url or self.DEFAULT_BASE_URL
self.timeout = timeout
self.max_retries = max_retries
self.container_timeout = container_timeout
self.container_poll_interval = container_poll_interval
self._client: Optional[httpx.AsyncClient] = None
self._account_id: Optional[str] = None
self._account_id_lock: asyncio.Lock = asyncio.Lock()
async def __aenter__(self) -> "InstagramClient":
"""비동기 컨텍스트 매니저 진입"""
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout),
follow_redirects=True,
)
logger.debug("[InstagramClient] HTTP 클라이언트 초기화 완료")
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
"""비동기 컨텍스트 매니저 종료"""
if self._client:
await self._client.aclose()
self._client = None
logger.debug("[InstagramClient] HTTP 클라이언트 종료")
def _get_client(self) -> httpx.AsyncClient:
"""HTTP 클라이언트 반환"""
if self._client is None:
raise RuntimeError(
"InstagramClient는 비동기 컨텍스트 매니저로 사용해야 합니다. "
"예: async with InstagramClient(access_token=...) as client:"
)
return self._client
def _build_url(self, endpoint: str) -> str:
"""API URL 생성"""
return f"{self.base_url}/{endpoint}"
async def _request(
self,
method: str,
endpoint: str,
params: Optional[dict[str, Any]] = None,
data: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""
공통 HTTP 요청 처리
- Rate Limit 지수 백오프 재시도
- 에러 응답 InstagramAPIError 발생
"""
client = self._get_client()
url = self._build_url(endpoint)
params = params or {}
params["access_token"] = self.access_token
retry_base_delay = 1.0
last_exception: Optional[Exception] = None
for attempt in range(self.max_retries + 1):
try:
logger.debug(
f"[API] {method} {endpoint} (attempt {attempt + 1}/{self.max_retries + 1})"
)
response = await client.request(
method=method,
url=url,
params=params,
data=data,
)
# Rate Limit 체크 (429)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
if attempt < self.max_retries:
wait_time = max(retry_base_delay * (2**attempt), retry_after)
logger.warning(f"Rate limit 초과. {wait_time}초 후 재시도...")
await asyncio.sleep(wait_time)
continue
raise RateLimitError(
message="Rate limit 초과 (최대 재시도 횟수 도달)",
retry_after=retry_after,
)
# 서버 에러 재시도 (5xx)
if response.status_code >= 500:
if attempt < self.max_retries:
wait_time = retry_base_delay * (2**attempt)
logger.warning(f"서버 에러 {response.status_code}. {wait_time}초 후 재시도...")
await asyncio.sleep(wait_time)
continue
response.raise_for_status()
# JSON 파싱
response_data = response.json()
# API 에러 체크 (Instagram API는 200 응답에도 error 포함 가능)
if "error" in response_data:
error_response = ErrorResponse.model_validate(response_data)
err = error_response.error
logger.error(f"[API Error] code={err.code}, message={err.message}")
raise create_exception_from_error(
message=err.message,
code=err.code,
subcode=err.error_subcode,
fbtrace_id=err.fbtrace_id,
)
return response_data
except InstagramAPIError:
raise
except httpx.HTTPError as e:
last_exception = e
if attempt < self.max_retries:
wait_time = retry_base_delay * (2**attempt)
logger.warning(f"HTTP 에러: {e}. {wait_time}초 후 재시도...")
await asyncio.sleep(wait_time)
continue
raise
# 이 지점에 도달하면 안 되지만, 타입 체커를 위해 명시적 raise
raise last_exception or InstagramAPIError("최대 재시도 횟수 초과")
async def _wait_for_container(
self,
container_id: str,
timeout: Optional[float] = None,
) -> MediaContainer:
"""컨테이너 상태가 FINISHED가 될 때까지 대기"""
timeout = timeout or self.container_timeout
start_time = time.monotonic()
logger.debug(f"[Container] 대기 시작: {container_id}, timeout={timeout}s")
while True:
elapsed = time.monotonic() - start_time
if elapsed >= timeout:
raise ContainerTimeoutError(
f"컨테이너 처리 타임아웃 ({timeout}초 초과): {container_id}"
)
response = await self._request(
method="GET",
endpoint=container_id,
params={"fields": "status_code,status"},
)
container = MediaContainer.model_validate(response)
logger.debug(f"[Container] status={container.status_code}, elapsed={elapsed:.1f}s")
if container.is_finished:
logger.info(f"[Container] 완료: {container_id}")
return container
if container.is_error:
raise ContainerStatusError(f"컨테이너 처리 실패: {container.status}")
await asyncio.sleep(self.container_poll_interval)
async def _get_account_id(self) -> str:
"""계정 ID 조회 (캐시됨, 동시성 안전)"""
if self._account_id:
return self._account_id
async with self._account_id_lock:
# Double-check after acquiring lock
if self._account_id:
return self._account_id
response = await self._request(
method="GET",
endpoint="me",
params={"fields": "id"},
)
account_id: str = response["id"]
self._account_id = account_id
logger.debug(f"[Account] ID 조회 완료: {account_id}")
return account_id
async def get_media_list(
self,
limit: int = 25,
after: Optional[str] = None,
) -> MediaList:
"""
미디어 목록 조회
Args:
limit: 조회할 미디어 (최대 100)
after: 페이지네이션 커서
Returns:
MediaList: 미디어 목록
Raises:
httpx.HTTPStatusError: API 에러 발생
"""
logger.info(f"[get_media_list] limit={limit}")
account_id = await self._get_account_id()
params: dict[str, Any] = {
"fields": "id,media_type,media_url,thumbnail_url,caption,timestamp,permalink,like_count,comments_count",
"limit": min(limit, 100),
}
if after:
params["after"] = after
response = await self._request(
method="GET",
endpoint=f"{account_id}/media",
params=params,
)
result = MediaList.model_validate(response)
logger.info(f"[get_media_list] 완료: {len(result.data)}")
return result
async def get_media(self, media_id: str) -> Media:
"""
미디어 상세 조회
Args:
media_id: 미디어 ID
Returns:
Media: 미디어 상세 정보
Raises:
httpx.HTTPStatusError: API 에러 발생
"""
logger.info(f"[get_media] media_id={media_id}")
response = await self._request(
method="GET",
endpoint=media_id,
params={
"fields": "id,media_type,media_url,thumbnail_url,caption,timestamp,permalink,like_count,comments_count,children{id,media_type,media_url}",
},
)
result = Media.model_validate(response)
logger.info(f"[get_media] 완료: type={result.media_type}, likes={result.like_count}")
return result
async def publish_image(
self,
image_url: str,
caption: Optional[str] = None,
) -> Media:
"""
이미지 게시
Args:
image_url: 공개 접근 가능한 이미지 URL (JPEG 권장)
caption: 게시물 캡션
Returns:
Media: 게시된 미디어 정보
Raises:
httpx.HTTPStatusError: API 에러 발생
TimeoutError: 컨테이너 처리 타임아웃
"""
logger.info(f"[publish_image] 시작: {image_url[:50]}...")
account_id = await self._get_account_id()
# Step 1: Container 생성
container_params: dict[str, Any] = {"image_url": image_url}
if caption:
container_params["caption"] = caption
container_response = await self._request(
method="POST",
endpoint=f"{account_id}/media",
params=container_params,
)
container_id = container_response["id"]
logger.debug(f"[publish_image] Container 생성: {container_id}")
# Step 2: Container 상태 대기
await self._wait_for_container(container_id)
# Step 3: 게시
publish_response = await self._request(
method="POST",
endpoint=f"{account_id}/media_publish",
params={"creation_id": container_id},
)
media_id = publish_response["id"]
result = await self.get_media(media_id)
logger.info(f"[publish_image] 완료: {result.permalink}")
return result
async def publish_video(
self,
video_url: str,
caption: Optional[str] = None,
share_to_feed: bool = True,
) -> Media:
"""
비디오/릴스 게시
Args:
video_url: 공개 접근 가능한 비디오 URL (MP4 권장)
caption: 게시물 캡션
share_to_feed: 피드에 공유 여부
Returns:
Media: 게시된 미디어 정보
Raises:
httpx.HTTPStatusError: API 에러 발생
TimeoutError: 컨테이너 처리 타임아웃
"""
logger.info(f"[publish_video] 시작: {video_url[:50]}...")
account_id = await self._get_account_id()
# Step 1: Container 생성
container_params: dict[str, Any] = {
"media_type": "REELS",
"video_url": video_url,
"share_to_feed": str(share_to_feed).lower(),
}
if caption:
container_params["caption"] = caption
container_response = await self._request(
method="POST",
endpoint=f"{account_id}/media",
params=container_params,
)
container_id = container_response["id"]
logger.debug(f"[publish_video] Container 생성: {container_id}")
# Step 2: Container 상태 대기 (비디오는 더 오래 걸림)
await self._wait_for_container(container_id, timeout=self.container_timeout * 2)
# Step 3: 게시
publish_response = await self._request(
method="POST",
endpoint=f"{account_id}/media_publish",
params={"creation_id": container_id},
)
media_id = publish_response["id"]
result = await self.get_media(media_id)
logger.info(f"[publish_video] 완료: {result.permalink}")
return result
async def publish_carousel(
self,
media_urls: list[str],
caption: Optional[str] = None,
) -> Media:
"""
캐러셀(멀티 이미지) 게시
Args:
media_urls: 이미지 URL 목록 (2-10)
caption: 게시물 캡션
Returns:
Media: 게시된 미디어 정보
Raises:
ValueError: 이미지 수가 2-10개가 아닌 경우
httpx.HTTPStatusError: API 에러 발생
TimeoutError: 컨테이너 처리 타임아웃
"""
if len(media_urls) < 2 or len(media_urls) > 10:
raise ValueError("캐러셀은 2-10개의 이미지가 필요합니다.")
logger.info(f"[publish_carousel] 시작: {len(media_urls)}개 이미지")
account_id = await self._get_account_id()
# Step 1: 각 이미지의 Container 병렬 생성
async def create_item_container(url: str, index: int) -> str:
response = await self._request(
method="POST",
endpoint=f"{account_id}/media",
params={"image_url": url, "is_carousel_item": "true"},
)
logger.debug(f"[publish_carousel] 이미지 {index + 1} Container 생성 완료")
return response["id"]
children_ids = await asyncio.gather(
*[create_item_container(url, i) for i, url in enumerate(media_urls)]
)
logger.debug(f"[publish_carousel] 모든 Container 생성 완료: {len(children_ids)}")
# Step 2: 캐러셀 Container 생성
carousel_params: dict[str, Any] = {
"media_type": "CAROUSEL",
"children": ",".join(children_ids),
}
if caption:
carousel_params["caption"] = caption
carousel_response = await self._request(
method="POST",
endpoint=f"{account_id}/media",
params=carousel_params,
)
carousel_id = carousel_response["id"]
# Step 3: Container 상태 대기
await self._wait_for_container(carousel_id)
# Step 4: 게시
publish_response = await self._request(
method="POST",
endpoint=f"{account_id}/media_publish",
params={"creation_id": carousel_id},
)
media_id = publish_response["id"]
result = await self.get_media(media_id)
logger.info(f"[publish_carousel] 완료: {result.permalink}")
return result

View File

@ -0,0 +1,142 @@
"""
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)
class AuthenticationError(InstagramAPIError):
"""
인증 관련 에러
토큰이 만료되었거나, 유효하지 않거나, 권한이 없는 경우 발생합니다.
"""
pass
class RateLimitError(InstagramAPIError):
"""
Rate Limit 초과 에러
시간당 API 호출 제한을 초과한 경우 발생합니다.
Attributes:
retry_after: 재시도까지 대기해야 하는 시간 ()
"""
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 ContainerStatusError(InstagramAPIError):
"""
컨테이너 상태 에러
미디어 컨테이너가 ERROR 상태가 되었을 발생합니다.
"""
pass
class ContainerTimeoutError(InstagramAPIError):
"""
컨테이너 타임아웃 에러
미디어 컨테이너가 지정된 시간 내에 FINISHED 상태가 되지 않은 경우 발생합니다.
"""
pass
# 에러 코드 → 예외 클래스 매핑
ERROR_CODE_MAPPING: dict[int, type[InstagramAPIError]] = {
4: RateLimitError,
17: RateLimitError,
190: AuthenticationError,
341: RateLimitError,
}
def create_exception_from_error(
message: str,
code: Optional[int] = None,
subcode: Optional[int] = None,
fbtrace_id: Optional[str] = None,
) -> InstagramAPIError:
"""
API 에러 응답에서 적절한 예외 객체 생성
Args:
message: 에러 메시지
code: API 에러 코드
subcode: API 에러 서브코드
fbtrace_id: Facebook 트레이스 ID
Returns:
적절한 예외 클래스의 인스턴스
"""
exception_class = InstagramAPIError
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,
)

View File

@ -0,0 +1,325 @@
"""
Instagram Graph API POC 테스트
파일은 InstagramClient의 기능을 테스트합니다.
실행 방법:
```bash
# 환경변수 설정
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
# 실행
python -m poc.instagram.main
```
주의사항:
- 게시 테스트는 실제로 Instagram에 게시됩니다.
- 테스트 토큰이 올바른지 확인하세요.
"""
import asyncio
import logging
import sys
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
def get_access_token() -> str:
"""환경변수에서 액세스 토큰 가져오기"""
token = "EAAmAhD98ZBY8BQg4PjcQrQFnHPoLLgMdbAsPz80oIVVQxAGjlAHgO1lyjzGsBi5ugIHPanmozFVyZAN4OZACESqeASAgn4rdxnyGYiWiGTME0uAm9dUmtYRpNJtlyslCkn9ee1YQVlZBgyS5PpVfXP1tV7cPJh2EHUZBwvsXnAZAYVDfdAKVZAy3kZB62VTugBt7"
if not token:
print("=" * 60)
print("오류: INSTAGRAM_ACCESS_TOKEN 환경변수가 설정되지 않았습니다.")
print()
print("설정 방법:")
print(" Windows PowerShell:")
print(' $env:INSTAGRAM_ACCESS_TOKEN = "your_token_here"')
print()
print(" Windows CMD:")
print(" set INSTAGRAM_ACCESS_TOKEN=your_token_here")
print()
print(" Linux/macOS:")
print(' export INSTAGRAM_ACCESS_TOKEN="your_token_here"')
print("=" * 60)
sys.exit(1)
return token
async def test_get_media_list():
"""미디어 목록 조회 테스트"""
from poc.instagram.client import InstagramClient
print("\n" + "=" * 60)
print("1. 미디어 목록 조회 테스트")
print("=" * 60)
access_token = get_access_token()
try:
async with InstagramClient(access_token=access_token) 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.next_cursor:
print(f"\n다음 페이지 있음 (cursor: {media_list.next_cursor[:20]}...)")
print("\n[성공] 미디어 목록 조회 완료")
except Exception as e:
print(f"\n[실패] 에러: {e}")
raise
async def test_get_media_detail():
"""미디어 상세 조회 테스트"""
from poc.instagram.client import InstagramClient
print("\n" + "=" * 60)
print("2. 미디어 상세 조회 테스트")
print("=" * 60)
access_token = get_access_token()
try:
async with InstagramClient(access_token=access_token) as client:
# 먼저 목록에서 첫 번째 미디어 ID 가져오기
media_list = await client.get_media_list(limit=1)
if not media_list.data:
print("\n게시물이 없습니다.")
return
media_id = media_list.data[0].id
print(f"\n조회할 미디어 ID: {media_id}")
# 상세 조회
media = await client.get_media(media_id)
print("\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("\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}")
print("\n[성공] 미디어 상세 조회 완료")
except Exception as e:
print(f"\n[실패] 에러: {e}")
raise
async def test_publish_image():
"""이미지 게시 테스트 (주석 처리됨 - 실제 게시됨)"""
print("\n" + "=" * 60)
print("3. 이미지 게시 테스트")
print("=" * 60)
# 테스트 설정 (공개 접근 가능한 이미지 URL 필요)
TEST_IMAGE_URL = "https://example.com/test-image.jpg"
TEST_CAPTION = "Test post from Instagram POC #test"
print("\n이 테스트는 실제로 게시물을 작성합니다!")
print(f" 이미지 URL: {TEST_IMAGE_URL}")
print(f" 캡션: {TEST_CAPTION}")
print("\n테스트하려면 아래 코드의 주석을 해제하세요.")
print("[건너뜀] 이미지 게시 테스트 (주석 처리됨)")
# ==========================================================================
# 실제 테스트 - 주석 해제 시 실행됨
# ==========================================================================
# from poc.instagram.exceptions import InstagramAPIError
# access_token = get_access_token()
#
# try:
# async with InstagramClient(access_token=access_token) 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 InstagramAPIError as e:
# print(f"\n[실패] 게시 실패: {e}")
# except Exception as e:
# print(f"\n[실패] 에러: {e}")
async def test_publish_video():
"""비디오/릴스 게시 테스트 (주석 처리됨 - 실제 게시됨)"""
print("\n" + "=" * 60)
print("4. 비디오/릴스 게시 테스트")
print("=" * 60)
TEST_VIDEO_URL = "https://f002.backblazeb2.com/file/creatomate-c8xg3hsxdu/9b1a680b-3481-4b22-94d4-a5cfd3e19f95.mp4"
TEST_CAPTION = "Test video from Instagram POC #test"
print("\n이 테스트는 실제로 게시물을 작성합니다!")
print(f" 비디오 URL: {TEST_VIDEO_URL}")
print(f" 캡션: {TEST_CAPTION}")
print("\n테스트하려면 아래 코드의 주석을 해제하세요.")
print("[건너뜀] 비디오 게시 테스트 (주석 처리됨)")
# ==========================================================================
# 실제 테스트 - 주석 해제 시 실행됨
# ==========================================================================
# from poc.instagram.exceptions import InstagramAPIError
# access_token = get_access_token()
#
# try:
# async with InstagramClient(access_token=access_token) 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 InstagramAPIError as e:
# print(f"\n[실패] 게시 실패: {e}")
# except Exception as e:
# print(f"\n[실패] 에러: {e}")
async def test_publish_carousel():
"""캐러셀 게시 테스트 (주석 처리됨 - 실제 게시됨)"""
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 POC #test"
print("\n이 테스트는 실제로 게시물을 작성합니다!")
print(f" 이미지 수: {len(TEST_IMAGE_URLS)}")
print(f" 캡션: {TEST_CAPTION}")
print("\n테스트하려면 아래 코드의 주석을 해제하세요.")
print("[건너뜀] 캐러셀 게시 테스트 (주석 처리됨)")
# ==========================================================================
# 실제 테스트 - 주석 해제 시 실행됨
# ==========================================================================
# from poc.instagram.exceptions import InstagramAPIError
# access_token = get_access_token()
#
# try:
# async with InstagramClient(access_token=access_token) 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 InstagramAPIError as e:
# print(f"\n[실패] 게시 실패: {e}")
# except Exception as e:
# print(f"\n[실패] 에러: {e}")
async def test_error_handling():
"""에러 처리 테스트"""
from poc.instagram.client import InstagramClient
from poc.instagram.exceptions import (
AuthenticationError,
InstagramAPIError,
RateLimitError,
)
print("\n" + "=" * 60)
print("6. 에러 처리 테스트")
print("=" * 60)
# 잘못된 토큰으로 테스트
print("\n잘못된 토큰으로 요청 테스트:")
try:
async with InstagramClient(access_token="INVALID_TOKEN") as client:
await client.get_media_list(limit=1)
print("[실패] 예외가 발생하지 않음")
except AuthenticationError as e:
print(f"[성공] AuthenticationError 발생: {e}")
except RateLimitError as e:
print(f"[성공] RateLimitError 발생: {e}")
if e.retry_after:
print(f" 재시도 대기 시간: {e.retry_after}")
except InstagramAPIError as e:
print(f"[성공] InstagramAPIError 발생: {e}")
print(f" 코드: {e.code}, 서브코드: {e.subcode}")
except Exception as e:
print(f"[성공] 예외 발생: {type(e).__name__}: {e}")
async def main():
"""모든 테스트 실행"""
print("\n" + "=" * 60)
print("Instagram Graph API POC 테스트")
print("=" * 60)
# 조회 테스트 (안전)
await test_get_media_list()
await test_get_media_detail()
# 게시 테스트 (기본 비활성화)
await test_publish_image()
await test_publish_video()
await test_publish_carousel()
# 에러 처리 테스트
await test_error_handling()
print("\n" + "=" * 60)
print("모든 테스트 완료")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,329 @@
"""
Instagram Graph API POC 테스트
파일은 InstagramClient의 기능을 테스트합니다.
실행 방법:
```bash
# 환경변수 설정
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
# 실행
python -m poc.instagram.main
```
주의사항:
- 게시 테스트는 실제로 Instagram에 게시됩니다.
- 테스트 토큰이 올바른지 확인하세요.
"""
import asyncio
import logging
import os
import sys
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
def get_access_token() -> str:
"""환경변수에서 액세스 토큰 가져오기"""
token = os.environ.get("INSTAGRAM_ACCESS_TOKEN")
if not token:
print("=" * 60)
print("오류: INSTAGRAM_ACCESS_TOKEN 환경변수가 설정되지 않았습니다.")
print()
print("설정 방법:")
print(" Windows PowerShell:")
print(' $env:INSTAGRAM_ACCESS_TOKEN = "your_token_here"')
print()
print(" Windows CMD:")
print(' set INSTAGRAM_ACCESS_TOKEN=your_token_here')
print()
print(" Linux/macOS:")
print(' export INSTAGRAM_ACCESS_TOKEN="your_token_here"')
print("=" * 60)
sys.exit(1)
return token
async def test_get_media_list():
"""미디어 목록 조회 테스트"""
from poc.instagram.client import InstagramClient
print("\n" + "=" * 60)
print("1. 미디어 목록 조회 테스트")
print("=" * 60)
access_token = get_access_token()
try:
async with InstagramClient(access_token=access_token) 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.next_cursor:
print(f"\n다음 페이지 있음 (cursor: {media_list.next_cursor[:20]}...)")
print("\n[성공] 미디어 목록 조회 완료")
except Exception as e:
print(f"\n[실패] 에러: {e}")
raise
async def test_get_media_detail():
"""미디어 상세 조회 테스트"""
from poc.instagram.client import InstagramClient
print("\n" + "=" * 60)
print("2. 미디어 상세 조회 테스트")
print("=" * 60)
access_token = get_access_token()
try:
async with InstagramClient(access_token=access_token) as client:
# 먼저 목록에서 첫 번째 미디어 ID 가져오기
media_list = await client.get_media_list(limit=1)
if not media_list.data:
print("\n게시물이 없습니다.")
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}")
print("\n[성공] 미디어 상세 조회 완료")
except Exception as e:
print(f"\n[실패] 에러: {e}")
raise
async def test_publish_image():
"""이미지 게시 테스트 (주석 처리됨 - 실제 게시됨)"""
from poc.instagram.client import InstagramClient
print("\n" + "=" * 60)
print("3. 이미지 게시 테스트")
print("=" * 60)
# 테스트 설정 (공개 접근 가능한 이미지 URL 필요)
TEST_IMAGE_URL = "https://example.com/test-image.jpg"
TEST_CAPTION = "Test post from Instagram POC #test"
print(f"\n이 테스트는 실제로 게시물을 작성합니다!")
print(f" 이미지 URL: {TEST_IMAGE_URL}")
print(f" 캡션: {TEST_CAPTION}")
print(f"\n테스트하려면 아래 코드의 주석을 해제하세요.")
print("[건너뜀] 이미지 게시 테스트 (주석 처리됨)")
# ==========================================================================
# 실제 테스트 - 주석 해제 시 실행됨
# ==========================================================================
# from poc.instagram.exceptions import InstagramAPIError
# access_token = get_access_token()
#
# try:
# async with InstagramClient(access_token=access_token) 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 InstagramAPIError as e:
# print(f"\n[실패] 게시 실패: {e}")
# except Exception as e:
# print(f"\n[실패] 에러: {e}")
async def test_publish_video():
"""비디오/릴스 게시 테스트 (주석 처리됨 - 실제 게시됨)"""
from poc.instagram.client import InstagramClient
print("\n" + "=" * 60)
print("4. 비디오/릴스 게시 테스트")
print("=" * 60)
TEST_VIDEO_URL = "https://example.com/test-video.mp4"
TEST_CAPTION = "Test video from Instagram POC #test"
print(f"\n이 테스트는 실제로 게시물을 작성합니다!")
print(f" 비디오 URL: {TEST_VIDEO_URL}")
print(f" 캡션: {TEST_CAPTION}")
print(f"\n테스트하려면 아래 코드의 주석을 해제하세요.")
print("[건너뜀] 비디오 게시 테스트 (주석 처리됨)")
# ==========================================================================
# 실제 테스트 - 주석 해제 시 실행됨
# ==========================================================================
# from poc.instagram.exceptions import InstagramAPIError
# access_token = get_access_token()
#
# try:
# async with InstagramClient(access_token=access_token) 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 InstagramAPIError as e:
# print(f"\n[실패] 게시 실패: {e}")
# except Exception as e:
# print(f"\n[실패] 에러: {e}")
async def test_publish_carousel():
"""캐러셀 게시 테스트 (주석 처리됨 - 실제 게시됨)"""
from poc.instagram.client import InstagramClient
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 POC #test"
print(f"\n이 테스트는 실제로 게시물을 작성합니다!")
print(f" 이미지 수: {len(TEST_IMAGE_URLS)}")
print(f" 캡션: {TEST_CAPTION}")
print(f"\n테스트하려면 아래 코드의 주석을 해제하세요.")
print("[건너뜀] 캐러셀 게시 테스트 (주석 처리됨)")
# ==========================================================================
# 실제 테스트 - 주석 해제 시 실행됨
# ==========================================================================
# from poc.instagram.exceptions import InstagramAPIError
# access_token = get_access_token()
#
# try:
# async with InstagramClient(access_token=access_token) 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 InstagramAPIError as e:
# print(f"\n[실패] 게시 실패: {e}")
# except Exception as e:
# print(f"\n[실패] 에러: {e}")
async def test_error_handling():
"""에러 처리 테스트"""
from poc.instagram.client import InstagramClient
from poc.instagram.exceptions import (
AuthenticationError,
InstagramAPIError,
RateLimitError,
)
print("\n" + "=" * 60)
print("6. 에러 처리 테스트")
print("=" * 60)
# 잘못된 토큰으로 테스트
print("\n잘못된 토큰으로 요청 테스트:")
try:
async with InstagramClient(access_token="INVALID_TOKEN") as client:
await client.get_media_list(limit=1)
print("[실패] 예외가 발생하지 않음")
except AuthenticationError as e:
print(f"[성공] AuthenticationError 발생: {e}")
except RateLimitError as e:
print(f"[성공] RateLimitError 발생: {e}")
if e.retry_after:
print(f" 재시도 대기 시간: {e.retry_after}")
except InstagramAPIError as e:
print(f"[성공] InstagramAPIError 발생: {e}")
print(f" 코드: {e.code}, 서브코드: {e.subcode}")
except Exception as e:
print(f"[성공] 예외 발생: {type(e).__name__}: {e}")
async def main():
"""모든 테스트 실행"""
print("\n" + "=" * 60)
print("Instagram Graph API POC 테스트")
print("=" * 60)
# 조회 테스트 (안전)
await test_get_media_list()
await test_get_media_detail()
# 게시 테스트 (기본 비활성화)
await test_publish_image()
await test_publish_video()
await test_publish_carousel()
# 에러 처리 테스트
await test_error_handling()
print("\n" + "=" * 60)
print("모든 테스트 완료")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,782 @@
# InstagramClient 사용 매뉴얼
Instagram Graph API를 사용한 콘텐츠 게시 및 조회를 위한 비동기 클라이언트입니다.
---
## 목차
1. [개요](#개요)
2. [클래스 구조](#클래스-구조)
3. [초기화 및 설정](#초기화-및-설정)
4. [메서드 상세](#메서드-상세)
5. [예외 처리](#예외-처리)
6. [데이터 모델](#데이터-모델)
7. [사용 예제](#사용-예제)
8. [내부 동작 원리](#내부-동작-원리)
---
## 개요
### 주요 특징
- **비동기 지원**: `asyncio` 기반의 비동기 HTTP 클라이언트
- **멀티테넌트**: 각 사용자가 자신의 `access_token`으로 독립적인 인스턴스 생성
- **자동 재시도**: Rate Limit 및 서버 에러 시 지수 백오프 재시도
- **컨텍스트 매니저**: `async with` 패턴으로 리소스 자동 관리
- **타입 힌트**: 완전한 타입 힌트 지원
### 지원 기능
| 기능 | 메서드 | 설명 |
|------|--------|------|
| 미디어 목록 조회 | `get_media_list()` | 계정의 게시물 목록 조회 |
| 미디어 상세 조회 | `get_media()` | 특정 게시물 상세 정보 |
| 이미지 게시 | `publish_image()` | 단일 이미지 게시 |
| 비디오/릴스 게시 | `publish_video()` | 비디오 또는 릴스 게시 |
| 캐러셀 게시 | `publish_carousel()` | 2-10개 이미지 게시 |
---
## 클래스 구조
### 파일 구조
```
poc/instagram/
├── __init__.py # 패키지 초기화 및 export
├── client.py # InstagramClient 클래스
├── exceptions.py # 커스텀 예외 클래스
├── models.py # Pydantic 데이터 모델
├── main.py # 테스트 실행 파일
└── manual.md # 본 문서
```
### 클래스 다이어그램
```
InstagramClient
├── __init__(access_token, ...) # 초기화
├── __aenter__() # 컨텍스트 진입
├── __aexit__() # 컨텍스트 종료
├── get_media_list() # 미디어 목록 조회
├── get_media() # 미디어 상세 조회
├── publish_image() # 이미지 게시
├── publish_video() # 비디오 게시
├── publish_carousel() # 캐러셀 게시
├── _request() # (내부) HTTP 요청 처리
├── _wait_for_container() # (내부) 컨테이너 대기
├── _get_account_id() # (내부) 계정 ID 조회
├── _get_client() # (내부) HTTP 클라이언트 반환
└── _build_url() # (내부) URL 생성
```
---
## 초기화 및 설정
### 생성자 파라미터
```python
InstagramClient(
access_token: str, # (필수) Instagram 액세스 토큰
*,
base_url: str = None, # API 기본 URL (기본값: https://graph.instagram.com/v21.0)
timeout: float = 30.0, # HTTP 요청 타임아웃 (초)
max_retries: int = 3, # 최대 재시도 횟수
container_timeout: float = 300.0, # 컨테이너 처리 대기 타임아웃 (초)
container_poll_interval: float = 5.0, # 컨테이너 상태 확인 간격 (초)
)
```
### 파라미터 상세 설명
| 파라미터 | 타입 | 기본값 | 설명 |
|----------|------|--------|------|
| `access_token` | `str` | (필수) | Instagram Graph API 액세스 토큰 |
| `base_url` | `str` | `https://graph.instagram.com/v21.0` | API 엔드포인트 기본 URL |
| `timeout` | `float` | `30.0` | 개별 HTTP 요청 타임아웃 (초) |
| `max_retries` | `int` | `3` | Rate Limit/서버 에러 시 재시도 횟수 |
| `container_timeout` | `float` | `300.0` | 미디어 컨테이너 처리 대기 최대 시간 (초) |
| `container_poll_interval` | `float` | `5.0` | 컨테이너 상태 확인 폴링 간격 (초) |
### 기본 사용법
```python
from poc.instagram import InstagramClient
async with InstagramClient(access_token="YOUR_TOKEN") as client:
# API 호출
media_list = await client.get_media_list()
```
### 커스텀 설정 사용
```python
async with InstagramClient(
access_token="YOUR_TOKEN",
timeout=60.0, # 타임아웃 60초
max_retries=5, # 최대 5회 재시도
container_timeout=600.0, # 컨테이너 대기 10분
) as client:
# 대용량 비디오 업로드 등에 적합
await client.publish_video(video_url="...", caption="...")
```
---
## 메서드 상세
### get_media_list()
계정의 미디어 목록을 조회합니다.
```python
async def get_media_list(
self,
limit: int = 25, # 조회할 미디어 수 (최대 100)
after: Optional[str] = None # 페이지네이션 커서
) -> MediaList
```
**파라미터:**
| 파라미터 | 타입 | 기본값 | 설명 |
|----------|------|--------|------|
| `limit` | `int` | `25` | 조회할 미디어 수 (최대 100) |
| `after` | `str` | `None` | 다음 페이지 커서 (페이지네이션) |
**반환값:** `MediaList` - 미디어 목록
**예외:**
- `InstagramAPIError` - API 에러 발생 시
- `AuthenticationError` - 인증 실패 시
- `RateLimitError` - Rate Limit 초과 시
**사용 예제:**
```python
# 기본 조회
media_list = await client.get_media_list()
# 10개만 조회
media_list = await client.get_media_list(limit=10)
# 페이지네이션
media_list = await client.get_media_list(limit=25)
if media_list.next_cursor:
next_page = await client.get_media_list(limit=25, after=media_list.next_cursor)
```
---
### get_media()
특정 미디어의 상세 정보를 조회합니다.
```python
async def get_media(
self,
media_id: str # 미디어 ID
) -> Media
```
**파라미터:**
| 파라미터 | 타입 | 설명 |
|----------|------|------|
| `media_id` | `str` | 조회할 미디어 ID |
**반환값:** `Media` - 미디어 상세 정보
**조회되는 필드:**
- `id`, `media_type`, `media_url`, `thumbnail_url`
- `caption`, `timestamp`, `permalink`
- `like_count`, `comments_count`
- `children` (캐러셀인 경우 하위 미디어)
**사용 예제:**
```python
media = await client.get_media("17895695668004550")
print(f"타입: {media.media_type}")
print(f"좋아요: {media.like_count}")
print(f"링크: {media.permalink}")
```
---
### publish_image()
단일 이미지를 게시합니다.
```python
async def publish_image(
self,
image_url: str, # 이미지 URL (공개 접근 가능)
caption: Optional[str] = None # 게시물 캡션
) -> Media
```
**파라미터:**
| 파라미터 | 타입 | 설명 |
|----------|------|------|
| `image_url` | `str` | 공개 접근 가능한 이미지 URL (JPEG 권장) |
| `caption` | `str` | 게시물 캡션 (해시태그, 멘션 포함 가능) |
**반환값:** `Media` - 게시된 미디어 정보
**이미지 요구사항:**
- 형식: JPEG 권장
- 최소 크기: 320x320 픽셀
- 비율: 4:5 ~ 1.91:1
- URL: 공개 접근 가능 (인증 없이)
**사용 예제:**
```python
media = await client.publish_image(
image_url="https://cdn.example.com/photo.jpg",
caption="오늘의 사진 #photography #daily"
)
print(f"게시 완료: {media.permalink}")
```
---
### publish_video()
비디오 또는 릴스를 게시합니다.
```python
async def publish_video(
self,
video_url: str, # 비디오 URL (공개 접근 가능)
caption: Optional[str] = None, # 게시물 캡션
share_to_feed: bool = True # 피드 공유 여부
) -> Media
```
**파라미터:**
| 파라미터 | 타입 | 기본값 | 설명 |
|----------|------|--------|------|
| `video_url` | `str` | (필수) | 공개 접근 가능한 비디오 URL (MP4 권장) |
| `caption` | `str` | `None` | 게시물 캡션 |
| `share_to_feed` | `bool` | `True` | 피드에 공유 여부 |
**반환값:** `Media` - 게시된 미디어 정보
**비디오 요구사항:**
- 형식: MP4 (H.264 코덱)
- 길이: 3초 ~ 60분 (릴스)
- 해상도: 최소 720p
- 비율: 9:16 (세로), 16:9 (가로), 1:1 (정사각형)
**참고:**
- 비디오 처리 시간이 이미지보다 오래 걸립니다
- 내부적으로 `container_timeout * 2` 시간까지 대기합니다
**사용 예제:**
```python
media = await client.publish_video(
video_url="https://cdn.example.com/video.mp4",
caption="새로운 릴스! #reels #trending",
share_to_feed=True
)
print(f"게시 완료: {media.permalink}")
```
---
### publish_carousel()
캐러셀(멀티 이미지)을 게시합니다.
```python
async def publish_carousel(
self,
media_urls: list[str], # 이미지 URL 목록 (2-10개)
caption: Optional[str] = None # 게시물 캡션
) -> Media
```
**파라미터:**
| 파라미터 | 타입 | 설명 |
|----------|------|------|
| `media_urls` | `list[str]` | 이미지 URL 목록 (2-10개 필수) |
| `caption` | `str` | 게시물 캡션 |
**반환값:** `Media` - 게시된 미디어 정보
**예외:**
- `ValueError` - 이미지 수가 2-10개가 아닌 경우
**특징:**
- 각 이미지의 컨테이너가 **병렬로** 생성됩니다 (성능 최적화)
- 모든 이미지가 동일한 요구사항을 충족해야 합니다
**사용 예제:**
```python
media = await client.publish_carousel(
media_urls=[
"https://cdn.example.com/img1.jpg",
"https://cdn.example.com/img2.jpg",
"https://cdn.example.com/img3.jpg",
],
caption="여행 사진 모음 #travel #photos"
)
print(f"게시 완료: {media.permalink}")
```
---
## 예외 처리
### 예외 계층 구조
```
Exception
└── InstagramAPIError # 기본 예외
├── AuthenticationError # 인증 오류 (code=190)
├── RateLimitError # Rate Limit (code=4, 17, 341)
├── ContainerStatusError # 컨테이너 ERROR 상태
└── ContainerTimeoutError # 컨테이너 타임아웃
```
### 예외 클래스 상세
#### InstagramAPIError
모든 Instagram API 예외의 기본 클래스입니다.
```python
class InstagramAPIError(Exception):
message: str # 에러 메시지
code: Optional[int] # API 에러 코드
subcode: Optional[int] # API 서브코드
fbtrace_id: Optional[str] # Facebook 트레이스 ID (디버깅용)
```
#### AuthenticationError
인증 관련 에러입니다.
- 토큰 만료
- 유효하지 않은 토큰
- 앱 권한 부족
```python
try:
await client.get_media_list()
except AuthenticationError as e:
print(f"인증 실패: {e.message}")
print(f"에러 코드: {e.code}") # 보통 190
```
#### RateLimitError
API 호출 제한 초과 에러입니다.
```python
class RateLimitError(InstagramAPIError):
retry_after: Optional[int] # 재시도까지 대기 시간 (초)
```
```python
try:
await client.get_media_list()
except RateLimitError as e:
print(f"Rate Limit 초과: {e.message}")
if e.retry_after:
print(f"{e.retry_after}초 후 재시도")
await asyncio.sleep(e.retry_after)
```
#### ContainerStatusError
미디어 컨테이너가 ERROR 상태가 된 경우 발생합니다.
- 잘못된 미디어 형식
- 지원하지 않는 코덱
- 미디어 URL 접근 불가
#### ContainerTimeoutError
컨테이너가 지정된 시간 내에 처리되지 않은 경우 발생합니다.
```python
try:
await client.publish_video(video_url="...", caption="...")
except ContainerTimeoutError as e:
print(f"타임아웃: {e}")
```
### 에러 코드 매핑
| 에러 코드 | 예외 클래스 | 설명 |
|-----------|-------------|------|
| 4 | `RateLimitError` | API 호출 제한 |
| 17 | `RateLimitError` | 사용자별 호출 제한 |
| 190 | `AuthenticationError` | 인증 실패 |
| 341 | `RateLimitError` | 앱 호출 제한 |
### 종합 예외 처리 예제
```python
from poc.instagram import (
InstagramClient,
AuthenticationError,
RateLimitError,
ContainerStatusError,
ContainerTimeoutError,
InstagramAPIError,
)
async with InstagramClient(access_token="YOUR_TOKEN") as client:
try:
media = await client.publish_image(
image_url="https://example.com/image.jpg",
caption="테스트"
)
print(f"성공: {media.permalink}")
except AuthenticationError as e:
print(f"인증 오류: {e}")
# 토큰 갱신 로직 실행
except RateLimitError as e:
print(f"Rate Limit: {e}")
if e.retry_after:
await asyncio.sleep(e.retry_after)
# 재시도
except ContainerStatusError as e:
print(f"미디어 처리 실패: {e}")
# 미디어 형식 확인
except ContainerTimeoutError as e:
print(f"처리 시간 초과: {e}")
# 더 긴 타임아웃으로 재시도
except InstagramAPIError as e:
print(f"API 에러: {e}")
print(f"코드: {e.code}, 서브코드: {e.subcode}")
except Exception as e:
print(f"예상치 못한 에러: {e}")
```
---
## 데이터 모델
### Media
미디어 정보를 담는 Pydantic 모델입니다.
```python
class Media(BaseModel):
id: str # 미디어 ID
media_type: Optional[str] # IMAGE, VIDEO, CAROUSEL_ALBUM
media_url: Optional[str] # 미디어 URL
thumbnail_url: Optional[str] # 썸네일 URL (비디오)
caption: Optional[str] # 캡션
timestamp: Optional[datetime] # 게시 시간
permalink: Optional[str] # 퍼머링크
like_count: int = 0 # 좋아요 수
comments_count: int = 0 # 댓글 수
children: Optional[list[Media]] # 캐러셀 하위 미디어
```
### MediaList
미디어 목록 응답 모델입니다.
```python
class MediaList(BaseModel):
data: list[Media] # 미디어 목록
paging: Optional[dict[str, Any]] # 페이지네이션 정보
@property
def next_cursor(self) -> Optional[str]:
"""다음 페이지 커서"""
```
### MediaContainer
미디어 컨테이너 상태 모델입니다.
```python
class MediaContainer(BaseModel):
id: str # 컨테이너 ID
status_code: Optional[str] # IN_PROGRESS, FINISHED, ERROR
status: Optional[str] # 상태 메시지
@property
def is_finished(self) -> bool: ...
@property
def is_error(self) -> bool: ...
@property
def is_in_progress(self) -> bool: ...
```
---
## 사용 예제
### 미디어 목록 조회 및 출력
```python
import asyncio
from poc.instagram import InstagramClient
async def main():
async with InstagramClient(access_token="YOUR_TOKEN") as client:
media_list = await client.get_media_list(limit=10)
for media in media_list.data:
print(f"[{media.media_type}] {media.caption[:30] if media.caption else '(캡션 없음)'}")
print(f" 좋아요: {media.like_count:,} | 댓글: {media.comments_count:,}")
print(f" 링크: {media.permalink}")
print()
asyncio.run(main())
```
### 이미지 게시
```python
async def post_image():
async with InstagramClient(access_token="YOUR_TOKEN") as client:
media = await client.publish_image(
image_url="https://cdn.example.com/photo.jpg",
caption="오늘의 사진 #photography"
)
return media.permalink
permalink = asyncio.run(post_image())
print(f"게시됨: {permalink}")
```
### 멀티테넌트 병렬 게시
여러 사용자가 동시에 게시물을 올리는 예제입니다.
```python
import asyncio
from poc.instagram import InstagramClient
async def post_for_user(user_id: str, token: str, image_url: str, caption: str):
"""특정 사용자의 계정에 게시"""
async with InstagramClient(access_token=token) as client:
media = await client.publish_image(image_url=image_url, caption=caption)
return {"user_id": user_id, "permalink": media.permalink}
async def main():
users = [
{"user_id": "user1", "token": "TOKEN1", "image": "https://...", "caption": "User1 post"},
{"user_id": "user2", "token": "TOKEN2", "image": "https://...", "caption": "User2 post"},
{"user_id": "user3", "token": "TOKEN3", "image": "https://...", "caption": "User3 post"},
]
# 병렬 실행
tasks = [
post_for_user(u["user_id"], u["token"], u["image"], u["caption"])
for u in users
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
print(f"실패: {result}")
else:
print(f"성공: {result['user_id']} -> {result['permalink']}")
asyncio.run(main())
```
### 페이지네이션으로 전체 미디어 조회
```python
async def get_all_media(client: InstagramClient, max_items: int = 100):
"""전체 미디어 조회 (페이지네이션)"""
all_media = []
cursor = None
while len(all_media) < max_items:
media_list = await client.get_media_list(limit=25, after=cursor)
all_media.extend(media_list.data)
if not media_list.next_cursor:
break
cursor = media_list.next_cursor
return all_media[:max_items]
```
---
## 내부 동작 원리
### HTTP 클라이언트 생명주기
```
async with InstagramClient(...) as client:
├── __aenter__()
│ └── httpx.AsyncClient 생성
├── API 호출들...
│ └── 동일한 HTTP 클라이언트 재사용 (연결 풀링)
└── __aexit__()
└── httpx.AsyncClient.aclose()
```
### 미디어 게시 프로세스
Instagram API의 미디어 게시는 3단계로 진행됩니다:
```
┌─────────────────────────────────────────────────────────┐
│ 미디어 게시 프로세스 │
├─────────────────────────────────────────────────────────┤
│ │
│ Step 1: Container 생성 │
│ POST /{account_id}/media │
│ ├── image_url / video_url 전달 │
│ └── Container ID 반환 │
│ │
│ Step 2: Container 상태 대기 (폴링) │
│ GET /{container_id}?fields=status_code │
│ ├── IN_PROGRESS: 계속 대기 │
│ ├── FINISHED: 다음 단계로 │
│ └── ERROR: ContainerStatusError 발생 │
│ │
│ Step 3: 게시 │
│ POST /{account_id}/media_publish │
│ └── Media ID 반환 │
│ │
└─────────────────────────────────────────────────────────┘
```
### 캐러셀 게시 프로세스
```
┌─────────────────────────────────────────────────────────┐
│ 캐러셀 게시 프로세스 │
├─────────────────────────────────────────────────────────┤
│ │
│ Step 1: 각 이미지 Container 병렬 생성 │
│ ├── asyncio.gather()로 동시 실행 │
│ └── children_ids = [id1, id2, id3, ...] │
│ │
│ Step 2: 캐러셀 Container 생성 │
│ POST /{account_id}/media │
│ ├── media_type: "CAROUSEL" │
│ └── children: "id1,id2,id3" │
│ │
│ Step 3: Container 상태 대기 │
│ │
│ Step 4: 게시 │
│ │
└─────────────────────────────────────────────────────────┘
```
### 자동 재시도 로직
```python
retry_base_delay = 1.0
for attempt in range(max_retries + 1):
try:
response = await client.request(...)
if response.status_code == 429: # Rate Limit
wait_time = max(retry_base_delay * (2 ** attempt), retry_after)
await asyncio.sleep(wait_time)
continue
if response.status_code >= 500: # 서버 에러
wait_time = retry_base_delay * (2 ** attempt)
await asyncio.sleep(wait_time)
continue
return response.json()
except httpx.HTTPError:
wait_time = retry_base_delay * (2 ** attempt)
await asyncio.sleep(wait_time)
continue
```
### 계정 ID 캐싱
계정 ID는 첫 조회 후 캐시됩니다:
```python
async def _get_account_id(self) -> str:
if self._account_id:
return self._account_id # 캐시 반환
async with self._account_id_lock: # 동시성 안전
if self._account_id:
return self._account_id
response = await self._request("GET", "me", {"fields": "id"})
self._account_id = response["id"]
return self._account_id
```
---
## API 제한사항
### Rate Limits
| 제한 | 값 | 설명 |
|------|-----|------|
| 시간당 요청 | 200회 | 사용자 토큰당 |
| 일일 게시 | 25개 | 계정당 (공식 문서 확인 필요) |
### 미디어 요구사항
**이미지:**
- 형식: JPEG 권장
- 최소 크기: 320x320 픽셀
- 비율: 4:5 ~ 1.91:1
**비디오:**
- 형식: MP4 (H.264)
- 길이: 3초 ~ 60분 (릴스)
- 해상도: 최소 720p
- 비율: 9:16 (세로), 16:9 (가로), 1:1 (정사각형)
**캐러셀:**
- 이미지 수: 2-10개
- 각 이미지는 위 요구사항 충족 필요
### URL 요구사항
게시할 미디어 URL은:
- HTTPS 프로토콜 권장
- 공개적으로 접근 가능 (인증 없이)
- CDN 또는 S3 등의 공개 URL 사용
---
## 참고 문서
- [Instagram Graph API 공식 문서](https://developers.facebook.com/docs/instagram-platform)
- [Content Publishing API](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing)
- [Graph API Explorer](https://developers.facebook.com/tools/explorer/)

View File

@ -0,0 +1,75 @@
"""
Instagram Graph API Pydantic 모델
API 응답 데이터를 위한 Pydantic 모델 정의입니다.
"""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class Media(BaseModel):
"""Instagram 미디어 정보"""
id: str
media_type: Optional[str] = None
media_url: Optional[str] = None
thumbnail_url: Optional[str] = None
caption: Optional[str] = None
timestamp: Optional[datetime] = None
permalink: Optional[str] = None
like_count: int = 0
comments_count: int = 0
children: Optional[list["Media"]] = None
class MediaList(BaseModel):
"""미디어 목록 응답"""
data: list[Media] = Field(default_factory=list)
paging: Optional[dict[str, Any]] = None
@property
def next_cursor(self) -> Optional[str]:
"""다음 페이지 커서"""
if self.paging and "cursors" in self.paging:
return self.paging["cursors"].get("after")
return None
class MediaContainer(BaseModel):
"""미디어 컨테이너 상태"""
id: str
status_code: Optional[str] = None
status: Optional[str] = None
@property
def is_finished(self) -> bool:
return self.status_code == "FINISHED"
@property
def is_error(self) -> bool:
return self.status_code == "ERROR"
@property
def is_in_progress(self) -> bool:
return self.status_code == "IN_PROGRESS"
class APIError(BaseModel):
"""API 에러 응답"""
message: str
type: Optional[str] = None
code: Optional[int] = None
error_subcode: Optional[int] = None
fbtrace_id: Optional[str] = None
class ErrorResponse(BaseModel):
"""에러 응답 래퍼"""
error: APIError

View File

@ -0,0 +1,266 @@
# Instagram Graph API POC
Instagram Graph API를 사용한 콘텐츠 게시 및 조회 클라이언트입니다.
## 개요
이 POC는 Instagram Graph API의 Content Publishing 기능을 테스트합니다.
### 지원 기능
| 기능 | 설명 | 메서드 |
|------|------|--------|
| 미디어 목록 조회 | 계정의 게시물 목록 조회 | `get_media_list()` |
| 미디어 상세 조회 | 특정 게시물 상세 정보 | `get_media()` |
| 이미지 게시 | 단일 이미지 게시 | `publish_image()` |
| 비디오/릴스 게시 | 비디오 또는 릴스 게시 | `publish_video()` |
| 캐러셀 게시 | 2-10개 이미지 게시 | `publish_carousel()` |
## 동작 원리
### 1. 인증 흐름
```
[사용자] → [Instagram 앱] → [Access Token 발급]
[InstagramClient(access_token=...)] ← 토큰 전달
```
Instagram Graph API는 OAuth 2.0 기반입니다:
1. Meta for Developers에서 앱 생성
2. Instagram Graph API 제품 추가
3. 사용자 인증 후 Access Token 발급
4. Token을 `InstagramClient`에 전달
### 2. 미디어 게시 프로세스
Instagram 미디어 게시는 3단계로 진행됩니다:
```
┌─────────────────────────────────────────────────────────────┐
│ 미디어 게시 프로세스 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Step 1: Container 생성 │
│ POST /{account_id}/media │
│ → Container ID 반환 │
│ │
│ Step 2: Container 상태 대기 │
│ GET /{container_id}?fields=status_code │
│ → IN_PROGRESS → FINISHED (폴링) │
│ │
│ Step 3: 게시 │
│ POST /{account_id}/media_publish │
│ → Media ID 반환 │
│ │
└─────────────────────────────────────────────────────────────┘
```
**캐러셀의 경우:**
1. 각 이미지마다 개별 Container 생성 (병렬 처리)
2. 캐러셀 Container 생성 (children ID 목록 전달)
3. 캐러셀 Container 상태 대기
4. 게시
### 3. HTTP 클라이언트 재사용
`InstagramClient``async with` 블록 내에서 HTTP 연결을 재사용합니다:
```python
async with InstagramClient(access_token="...") as client:
# 이 블록 내의 모든 API 호출은 동일한 HTTP 클라이언트 사용
await client.get_media_list() # 연결 1
await client.publish_image(...) # 연결 재사용 (4+ 요청)
await client.get_media(...) # 연결 재사용
```
## 환경 설정
### 1. 필수 환경변수
```bash
# Instagram Access Token (필수)
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
```
### 2. 의존성 설치
```bash
uv add httpx pydantic
```
### 3. Access Token 발급 방법
1. [Meta for Developers](https://developers.facebook.com/)에서 앱 생성
2. Instagram Graph API 제품 추가
3. 권한 설정:
- `instagram_basic` - 기본 프로필 정보
- `instagram_content_publish` - 콘텐츠 게시
4. Graph API Explorer에서 토큰 발급
## 사용 예제
### 기본 사용법
```python
import asyncio
from poc.instagram.client import InstagramClient
async def main():
async with InstagramClient(access_token="YOUR_TOKEN") as client:
# 미디어 목록 조회
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 InstagramClient(access_token="YOUR_TOKEN") as client:
media = await client.publish_image(
image_url="https://example.com/photo.jpg",
caption="My photo! #photography"
)
print(f"게시 완료: {media.permalink}")
```
### 비디오/릴스 게시
```python
async with InstagramClient(access_token="YOUR_TOKEN") as client:
media = await client.publish_video(
video_url="https://example.com/video.mp4",
caption="Check this out! #video",
share_to_feed=True
)
print(f"게시 완료: {media.permalink}")
```
### 캐러셀 게시
```python
async with InstagramClient(access_token="YOUR_TOKEN") as client:
media = await client.publish_carousel(
media_urls=[
"https://example.com/img1.jpg",
"https://example.com/img2.jpg",
"https://example.com/img3.jpg",
],
caption="My carousel! #photos"
)
print(f"게시 완료: {media.permalink}")
```
### 에러 처리
```python
import httpx
from poc.instagram.client import InstagramClient
async with InstagramClient(access_token="YOUR_TOKEN") as client:
try:
media = await client.publish_image(...)
except httpx.HTTPStatusError as e:
print(f"API 오류: {e}")
print(f"상태 코드: {e.response.status_code}")
except TimeoutError as e:
print(f"타임아웃: {e}")
except RuntimeError as e:
print(f"컨테이너 처리 실패: {e}")
except Exception as e:
print(f"예상치 못한 오류: {e}")
```
### 멀티테넌트 사용
여러 사용자가 각자의 토큰으로 독립적인 인스턴스를 사용합니다:
```python
async def post_for_user(user_token: str, image_url: str, caption: str):
async with InstagramClient(access_token=user_token) as client:
return await client.publish_image(image_url=image_url, caption=caption)
# 여러 사용자에 대해 병렬 실행
results = await asyncio.gather(
post_for_user("USER1_TOKEN", "https://...", "User 1 post"),
post_for_user("USER2_TOKEN", "https://...", "User 2 post"),
post_for_user("USER3_TOKEN", "https://...", "User 3 post"),
)
```
## API 제한사항
### Rate Limits
| 제한 | 값 | 설명 |
|------|-----|------|
| 시간당 요청 | 200회 | 사용자 토큰당 |
| 일일 게시 | 25개 | 계정당 (공식 문서 확인 필요) |
Rate limit 초과 시 `RateLimitError`가 발생하며, `retry_after` 속성으로 대기 시간을 확인할 수 있습니다.
### 미디어 요구사항
**이미지:**
- 형식: JPEG 권장
- 최소 크기: 320x320 픽셀
- 비율: 4:5 ~ 1.91:1
**비디오:**
- 형식: MP4 (H.264)
- 길이: 3초 ~ 60분 (릴스)
- 해상도: 최소 720p
- 비율: 9:16 (세로), 16:9 (가로), 1:1 (정사각형)
**캐러셀:**
- 이미지 수: 2-10개
- 각 이미지는 위 이미지 요구사항 충족 필요
### 미디어 URL 요구사항
게시할 미디어는 **공개적으로 접근 가능한 URL**이어야 합니다:
- HTTPS 프로토콜 권장
- 인증 없이 접근 가능해야 함
- CDN 또는 S3 등의 공개 URL 사용
## 예외 처리
표준 Python 및 httpx 예외를 사용합니다:
| 예외 | 설명 | 원인 |
|------|------|------|
| `httpx.HTTPStatusError` | HTTP 상태 에러 | API 에러 응답 (4xx, 5xx) |
| `httpx.HTTPError` | HTTP 통신 에러 | 네트워크 오류, 재시도 초과 |
| `TimeoutError` | 타임아웃 | 컨테이너 처리 시간 초과 |
| `RuntimeError` | 런타임 에러 | 컨테이너 처리 실패, 컨텍스트 매니저 미사용 |
| `ValueError` | 값 에러 | 잘못된 파라미터 (토큰 누락, 캐러셀 이미지 수 등) |
## 테스트 실행
```bash
# 환경변수 설정
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
# 테스트 실행
python -m poc.instagram.main
```
## 파일 구조
```
poc/instagram/
├── __init__.py # 패키지 초기화 및 export
├── client.py # InstagramClient 클래스
├── models.py # Pydantic 모델 (Media, MediaList 등)
├── main.py # 테스트 실행 파일
└── poc.md # 사용 매뉴얼 (본 문서)
```
## 참고 문서
- [Instagram Graph API 공식 문서](https://developers.facebook.com/docs/instagram-platform)
- [Content Publishing API](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing)
- [Graph API Explorer](https://developers.facebook.com/tools/explorer/)