diff --git a/app/admin_manager.py b/app/admin_manager.py index 35cd37a..19c5628 100644 --- a/app/admin_manager.py +++ b/app/admin_manager.py @@ -5,6 +5,8 @@ from app.database.session import engine from app.home.api.home_admin import ImageAdmin, ProjectAdmin from app.lyric.api.lyrics_admin import LyricAdmin from app.song.api.song_admin import SongAdmin +from app.sns.api.sns_admin import SNSUploadTaskAdmin +from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin from app.video.api.video_admin import VideoAdmin from config import prj_settings @@ -35,4 +37,12 @@ def init_admin( # 영상 관리 admin.add_view(VideoAdmin) + # 사용자 관리 + admin.add_view(UserAdmin) + admin.add_view(RefreshTokenAdmin) + admin.add_view(SocialAccountAdmin) + + # SNS 관리 + admin.add_view(SNSUploadTaskAdmin) + return admin diff --git a/app/database/session.py b/app/database/session.py index e3e54dd..d60db3c 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -77,6 +77,7 @@ async def create_db_tables(): from app.lyric.models import Lyric # noqa: F401 from app.song.models import Song, SongTimestamp # noqa: F401 from app.video.models import Video # noqa: F401 + from app.sns.models import SNSUploadTask # noqa: F401 # 생성할 테이블 목록 tables_to_create = [ @@ -89,6 +90,7 @@ async def create_db_tables(): Song.__table__, SongTimestamp.__table__, Video.__table__, + SNSUploadTask.__table__, ] logger.info("Creating database tables...") diff --git a/app/sns/api/sns_admin.py b/app/sns/api/sns_admin.py index e69de29..1c7cb23 100644 --- a/app/sns/api/sns_admin.py +++ b/app/sns/api/sns_admin.py @@ -0,0 +1,72 @@ +from sqladmin import ModelView + +from app.sns.models import SNSUploadTask + + +class SNSUploadTaskAdmin(ModelView, model=SNSUploadTask): + name = "SNS 업로드 작업" + name_plural = "SNS 업로드 작업 목록" + icon = "fa-solid fa-share-from-square" + category = "SNS 관리" + page_size = 20 + + column_list = [ + "id", + "user_uuid", + "task_id", + "social_account_id", + "is_scheduled", + "status", + "scheduled_at", + "uploaded_at", + "created_at", + ] + + column_details_list = [ + "id", + "user_uuid", + "task_id", + "social_account_id", + "is_scheduled", + "scheduled_at", + "url", + "caption", + "status", + "uploaded_at", + "created_at", + ] + + form_excluded_columns = ["created_at", "user", "social_account"] + + column_searchable_list = [ + SNSUploadTask.user_uuid, + SNSUploadTask.task_id, + SNSUploadTask.status, + ] + + column_default_sort = (SNSUploadTask.created_at, True) + + column_sortable_list = [ + SNSUploadTask.id, + SNSUploadTask.user_uuid, + SNSUploadTask.social_account_id, + SNSUploadTask.is_scheduled, + SNSUploadTask.status, + SNSUploadTask.scheduled_at, + SNSUploadTask.uploaded_at, + SNSUploadTask.created_at, + ] + + column_labels = { + "id": "ID", + "user_uuid": "사용자 UUID", + "task_id": "작업 ID", + "social_account_id": "소셜 계정 ID", + "is_scheduled": "예약 여부", + "scheduled_at": "예약 일시", + "url": "미디어 URL", + "caption": "캡션", + "status": "상태", + "uploaded_at": "업로드 일시", + "created_at": "생성일시", + } diff --git a/app/user/api/user_admin.py b/app/user/api/user_admin.py index 7f70ecb..af39add 100644 --- a/app/user/api/user_admin.py +++ b/app/user/api/user_admin.py @@ -160,16 +160,17 @@ class SocialAccountAdmin(ModelView, model=SocialAccount): column_list = [ "id", - "user_id", + "user_uuid", "platform", "platform_username", "is_active", - "connected_at", + "is_deleted", + "created_at", ] column_details_list = [ "id", - "user_id", + "user_uuid", "platform", "platform_user_id", "platform_username", @@ -177,32 +178,34 @@ class SocialAccountAdmin(ModelView, model=SocialAccount): "scope", "token_expires_at", "is_active", - "connected_at", + "is_deleted", + "created_at", "updated_at", ] - form_excluded_columns = ["connected_at", "updated_at", "user"] + form_excluded_columns = ["created_at", "updated_at", "user"] column_searchable_list = [ - SocialAccount.user_id, + SocialAccount.user_uuid, SocialAccount.platform, SocialAccount.platform_user_id, SocialAccount.platform_username, ] - column_default_sort = (SocialAccount.connected_at, True) + column_default_sort = (SocialAccount.created_at, True) column_sortable_list = [ SocialAccount.id, - SocialAccount.user_id, + SocialAccount.user_uuid, SocialAccount.platform, SocialAccount.is_active, - SocialAccount.connected_at, + SocialAccount.is_deleted, + SocialAccount.created_at, ] column_labels = { "id": "ID", - "user_id": "사용자 ID", + "user_uuid": "사용자 UUID", "platform": "플랫폼", "platform_user_id": "플랫폼 사용자 ID", "platform_username": "플랫폼 사용자명", @@ -210,6 +213,7 @@ class SocialAccountAdmin(ModelView, model=SocialAccount): "scope": "권한 범위", "token_expires_at": "토큰 만료일시", "is_active": "활성화", - "connected_at": "연동일시", + "is_deleted": "삭제됨", + "created_at": "생성일시", "updated_at": "수정일시", } diff --git a/app/user/schemas/social_account_schema.py b/app/user/schemas/social_account_schema.py index 0972aca..1ee6b10 100644 --- a/app/user/schemas/social_account_schema.py +++ b/app/user/schemas/social_account_schema.py @@ -23,7 +23,7 @@ class SocialAccountCreateRequest(BaseModel): refresh_token: Optional[str] = Field(None, description="OAuth 리프레시 토큰") token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시") scope: Optional[str] = Field(None, description="허용된 권한 범위") - platform_user_id: str = Field(..., min_length=1, description="플랫폼 내 사용자 고유 ID") + platform_user_id: Optional[str] = Field(None, description="플랫폼 내 사용자 고유 ID") platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들") platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보") @@ -33,14 +33,11 @@ class SocialAccountCreateRequest(BaseModel): "platform": "instagram", "access_token": "IGQWRPcG...", "refresh_token": None, - "token_expires_at": "2026-03-15T10:30:00", - "scope": "instagram_basic,instagram_content_publish", - "platform_user_id": "17841400000000000", - "platform_username": "my_instagram_account", - "platform_data": { - "business_account_id": "17841400000000000", - "facebook_page_id": "123456789" - } + "token_expires_at": None, + "scope": None, + "platform_user_id": None, + "platform_username": None, + "platform_data": None, } } } @@ -76,7 +73,7 @@ class SocialAccountResponse(BaseModel): account_id: int = Field(..., validation_alias="id", description="소셜 계정 ID") platform: Platform = Field(..., description="플랫폼 구분") - platform_user_id: str = Field(..., description="플랫폼 내 사용자 고유 ID") + platform_user_id: Optional[str] = Field(None, description="플랫폼 내 사용자 고유 ID") platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들") platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보") scope: Optional[str] = Field(None, description="허용된 권한 범위") diff --git a/app/utils/upload_blob_as_request.py b/app/utils/upload_blob_as_request.py index 8ca2132..7012f76 100644 --- a/app/utils/upload_blob_as_request.py +++ b/app/utils/upload_blob_as_request.py @@ -32,6 +32,7 @@ URL 경로 형식: """ import asyncio +import re import time from pathlib import Path @@ -129,6 +130,33 @@ class AzureBlobUploader: """마지막 업로드의 공개 URL (SAS 토큰 제외)""" return self._last_public_url + def _sanitize_filename(self, file_name: str) -> str: + """파일명에서 공백/특수문자 제거, 한글/영문/숫자만 허용 + + Args: + file_name: 원본 파일명 + + Returns: + str: 정리된 파일명 (한글, 영문, 숫자만 포함) + + Example: + >>> self._sanitize_filename("my file (1).mp4") + 'myfile1.mp4' + >>> self._sanitize_filename("테스트 파일!@#.png") + '테스트파일.png' + """ + stem = Path(file_name).stem + suffix = Path(file_name).suffix + + # 한글(가-힣), 영문(a-zA-Z), 숫자(0-9)만 남기고 제거 + sanitized = re.sub(r'[^가-힣a-zA-Z0-9]', '', stem) + + # 빈 문자열이면 기본값 사용 + if not sanitized: + sanitized = "file" + + return f"{sanitized}{suffix}" + def _build_upload_url(self, category: str, file_name: str) -> str: """업로드 URL 생성 (SAS 토큰 포함)""" # SAS 토큰 앞뒤의 ?, ', " 제거 @@ -238,8 +266,8 @@ class AzureBlobUploader: Returns: bool: 업로드 성공 여부 """ - # 파일 경로에서 파일명 추출 - file_name = Path(file_path).name + # 파일 경로에서 파일명 추출 후 정리 (공백/특수문자 제거) + file_name = self._sanitize_filename(Path(file_path).name) upload_url = self._build_upload_url(category, file_name) self._last_public_url = self._build_public_url(category, file_name) @@ -301,7 +329,8 @@ class AzureBlobUploader: success = await uploader.upload_music_bytes(audio_bytes, "my_song") print(uploader.public_url) """ - # 확장자가 없으면 .mp3 추가 + # 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp3 추가 + file_name = self._sanitize_filename(file_name) if not Path(file_name).suffix: file_name = f"{file_name}.mp3" @@ -363,7 +392,8 @@ class AzureBlobUploader: success = await uploader.upload_video_bytes(video_bytes, "my_video") print(uploader.public_url) """ - # 확장자가 없으면 .mp4 추가 + # 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp4 추가 + file_name = self._sanitize_filename(file_name) if not Path(file_name).suffix: file_name = f"{file_name}.mp4" @@ -430,9 +460,13 @@ class AzureBlobUploader: success = await uploader.upload_image_bytes(content, "my_image.png") print(uploader.public_url) """ + # Content-Type 결정을 위해 먼저 확장자 추출 extension = Path(file_name).suffix.lower() content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg") + # 파일명 정리 (공백/특수문자 제거) + file_name = self._sanitize_filename(file_name) + upload_url = self._build_upload_url("image", file_name) self._last_public_url = self._build_public_url("image", file_name) log_prefix = "upload_image_bytes" diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 9f268b7..dfe5f2f 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -551,23 +551,15 @@ async def get_video_status( if video and video.status != "completed": # 이미 완료된 경우 백그라운드 작업 중복 실행 방지 - # task_id로 Project 조회하여 store_name 가져오기 - project_result = await session.execute( - select(Project).where(Project.id == video.project_id) - ) - project = project_result.scalar_one_or_none() - - store_name = project.store_name if project else "video" - # 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제 logger.info( - f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}" + f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, creatomate_render_id: {creatomate_render_id}" ) background_tasks.add_task( download_and_upload_video_to_blob, task_id=video.task_id, video_url=video_url, - store_name=store_name, + creatomate_render_id=creatomate_render_id, user_uuid=current_user.user_uuid, ) elif video and video.status == "completed": diff --git a/app/video/worker/video_task.py b/app/video/worker/video_task.py index 4680a32..06616d5 100644 --- a/app/video/worker/video_task.py +++ b/app/video/worker/video_task.py @@ -105,27 +105,25 @@ async def _download_video(url: str, task_id: str) -> bytes: async def download_and_upload_video_to_blob( task_id: str, video_url: str, - store_name: str, + creatomate_render_id: str, user_uuid: str, ) -> None: """백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. + 파일명은 creatomate_render_id를 사용하여 고유성을 보장합니다. + Args: task_id: 프로젝트 task_id video_url: 다운로드할 영상 URL - store_name: 저장할 파일명에 사용할 업체명 + creatomate_render_id: Creatomate API 렌더 ID (파일명으로 사용) user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용) """ - logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}") + logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}") temp_file_path: Path | None = None try: - # 파일명에 사용할 수 없는 문자 제거 - safe_store_name = "".join( - c for c in store_name if c.isalnum() or c in (" ", "_", "-") - ).strip() - safe_store_name = safe_store_name or "video" - file_name = f"{safe_store_name}.mp4" + # creatomate_render_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요) + file_name = f"{creatomate_render_id}.mp4" # 임시 저장 경로 생성 temp_dir = Path("media") / "temp" / task_id @@ -191,18 +189,18 @@ async def download_and_upload_video_to_blob( async def download_and_upload_video_by_creatomate_render_id( creatomate_render_id: str, video_url: str, - store_name: str, user_uuid: str, ) -> None: """creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. + 파일명은 creatomate_render_id를 사용하여 고유성을 보장합니다. + Args: - creatomate_render_id: Creatomate API 렌더 ID + creatomate_render_id: Creatomate API 렌더 ID (파일명으로도 사용) video_url: 다운로드할 영상 URL - store_name: 저장할 파일명에 사용할 업체명 user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용) """ - logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}") + logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}") temp_file_path: Path | None = None task_id: str | None = None @@ -224,12 +222,8 @@ async def download_and_upload_video_by_creatomate_render_id( task_id = video.task_id logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}") - # 파일명에 사용할 수 없는 문자 제거 - safe_store_name = "".join( - c for c in store_name if c.isalnum() or c in (" ", "_", "-") - ).strip() - safe_store_name = safe_store_name or "video" - file_name = f"{safe_store_name}.mp4" + # creatomate_render_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요) + file_name = f"{creatomate_render_id}.mp4" # 임시 저장 경로 생성 temp_dir = Path("media") / "temp" / task_id diff --git a/insta_plan.md b/insta_plan.md deleted file mode 100644 index 6f1a57f..0000000 --- a/insta_plan.md +++ /dev/null @@ -1,558 +0,0 @@ -# Instagram POC 예외 처리 단순화 작업 계획서 - -## 개요 - -`poc/instagram/exceptions.py` 파일을 삭제하고, `client.py` 상단에 **ErrorState Enum과 에러 처리 유틸리티**를 정의하여 일관된 에러 처리 구조를 구현합니다. - ---- - -## 최종 파일 구조 - -``` -poc/instagram/ -├── client.py # ErrorState + parse_instagram_error + InstagramClient -├── models.py -├── __init__.py # client.py에서 ErrorState, parse_instagram_error export -└── (exceptions.py 삭제) -``` - ---- - -## 작업 계획 - -### 1단계: client.py 상단에 에러 처리 코드 추가 - -**파일**: `poc/instagram/client.py` - -**위치**: import 문 다음, InstagramClient 클래스 이전 - -**추가할 코드**: -```python -import re -from enum import Enum - -# ============================================================ -# Error State & Parser -# ============================================================ - -class ErrorState(str, Enum): - """Instagram API 에러 상태""" - RATE_LIMIT = "rate_limit" - AUTH_ERROR = "auth_error" - CONTAINER_TIMEOUT = "container_timeout" - CONTAINER_ERROR = "container_error" - API_ERROR = "api_error" - UNKNOWN = "unknown" - - -def parse_instagram_error(e: Exception) -> tuple[ErrorState, str, dict]: - """ - Instagram 예외를 파싱하여 상태, 메시지, 추가 정보를 반환 - - Args: - e: 발생한 예외 - - Returns: - tuple: (error_state, message, extra_info) - - Example: - >>> error_state, message, extra_info = parse_instagram_error(e) - >>> if error_state == ErrorState.RATE_LIMIT: - ... retry_after = extra_info.get("retry_after", 60) - """ - error_str = str(e) - extra_info = {} - - # Rate Limit 에러 - if "[RateLimit]" in error_str: - match = re.search(r"retry_after=(\d+)s", error_str) - if match: - extra_info["retry_after"] = int(match.group(1)) - return ErrorState.RATE_LIMIT, "API 호출 제한 초과", extra_info - - # 인증 에러 (code=190) - if "code=190" in error_str: - return ErrorState.AUTH_ERROR, "인증 실패 (토큰 만료 또는 무효)", extra_info - - # 컨테이너 타임아웃 - if "[ContainerTimeout]" in error_str: - match = re.search(r"\((\d+)초 초과\)", error_str) - if match: - extra_info["timeout"] = int(match.group(1)) - return ErrorState.CONTAINER_TIMEOUT, "미디어 처리 시간 초과", extra_info - - # 컨테이너 상태 에러 - if "[ContainerStatus]" in error_str: - match = re.search(r"처리 실패: (\w+)", error_str) - if match: - extra_info["status"] = match.group(1) - return ErrorState.CONTAINER_ERROR, "미디어 컨테이너 처리 실패", extra_info - - # Instagram API 에러 - if "[InstagramAPI]" in error_str: - match = re.search(r"code=(\d+)", error_str) - if match: - extra_info["code"] = int(match.group(1)) - return ErrorState.API_ERROR, "Instagram API 오류", extra_info - - return ErrorState.UNKNOWN, str(e), extra_info -``` - ---- - -### 2단계: client.py import 문 수정 - -**파일**: `poc/instagram/client.py` - -**변경 전** (line 24-30): -```python -from .exceptions import ( - ContainerStatusError, - ContainerTimeoutError, - InstagramAPIError, - RateLimitError, - create_exception_from_error, -) -``` - -**변경 후**: -```python -# (삭제 - ErrorState와 parse_instagram_error를 직접 정의) -``` - -**import 추가**: -```python -import re -from enum import Enum -``` - ---- - -### 3단계: 예외 발생 코드 수정 - -#### 3-1. Rate Limit 에러 (line 159-162) - -**변경 전**: -```python -raise RateLimitError( - message="Rate limit 초과 (최대 재시도 횟수 도달)", - retry_after=retry_after, -) -``` - -**변경 후**: -```python -raise Exception(f"[RateLimit] Rate limit 초과 (최대 재시도 횟수 도달) | retry_after={retry_after}s") -``` - ---- - -#### 3-2. API 에러 응답 (line 177-186) - -**변경 전**: -```python -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, - ) -``` - -**변경 후**: -```python -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}") - error_msg = f"[InstagramAPI] {err.message} | code={err.code}" - if err.error_subcode: - error_msg += f" | subcode={err.error_subcode}" - if err.fbtrace_id: - error_msg += f" | fbtrace_id={err.fbtrace_id}" - raise Exception(error_msg) -``` - ---- - -#### 3-3. 예외 재발생 (line 190-191) - -**변경 전**: -```python -except InstagramAPIError: - raise -``` - -**변경 후**: -```python -except Exception: - raise -``` - ---- - -#### 3-4. 최대 재시도 초과 (line 201) - -**변경 전**: -```python -raise last_exception or InstagramAPIError("최대 재시도 횟수 초과") -``` - -**변경 후**: -```python -raise last_exception or Exception("[InstagramAPI] 최대 재시도 횟수 초과") -``` - ---- - -#### 3-5. 컨테이너 타임아웃 (line 217-218) - -**변경 전**: -```python -raise ContainerTimeoutError( - f"컨테이너 처리 타임아웃 ({timeout}초 초과): {container_id}" -) -``` - -**변경 후**: -```python -raise Exception(f"[ContainerTimeout] 컨테이너 처리 타임아웃 ({timeout}초 초과): {container_id}") -``` - ---- - -#### 3-6. 컨테이너 상태 에러 (line 235) - -**변경 전**: -```python -raise ContainerStatusError(f"컨테이너 처리 실패: {container.status}") -``` - -**변경 후**: -```python -raise Exception(f"[ContainerStatus] 컨테이너 처리 실패: {container.status}") -``` - ---- - -### 4단계: __init__.py 수정 - -**파일**: `poc/instagram/__init__.py` - -**변경 전** (line 18-25): -```python -from poc.instagram.client import InstagramClient -from poc.instagram.exceptions import ( - InstagramAPIError, - AuthenticationError, - RateLimitError, - ContainerStatusError, - ContainerTimeoutError, -) -``` - -**변경 후**: -```python -from poc.instagram.client import ( - InstagramClient, - ErrorState, - parse_instagram_error, -) -``` - -**__all__ 수정**: -```python -__all__ = [ - # Client - "InstagramClient", - # Error handling - "ErrorState", - "parse_instagram_error", - # Models - "Media", - "MediaList", - "MediaContainer", - "APIError", - "ErrorResponse", -] -``` - ---- - -### 5단계: main.py 수정 - -**파일**: `poc/instagram/main.py` - -**변경 전** (line 13): -```python -from poc.instagram.exceptions import InstagramAPIError -``` - -**변경 후**: -```python -from poc.instagram import ErrorState, parse_instagram_error -``` - -**예외 처리 수정**: -```python -# 변경 전 -except InstagramAPIError as e: - logger.error(f"API 에러: {e}") - -# 변경 후 -except Exception as e: - error_state, message, extra_info = parse_instagram_error(e) - - if error_state == ErrorState.RATE_LIMIT: - retry_after = extra_info.get("retry_after", 60) - logger.error(f"Rate Limit: {message} (재시도: {retry_after}초)") - elif error_state == ErrorState.AUTH_ERROR: - logger.error(f"인증 에러: {message}") - elif error_state == ErrorState.CONTAINER_TIMEOUT: - logger.error(f"타임아웃: {message}") - elif error_state == ErrorState.CONTAINER_ERROR: - status = extra_info.get("status", "UNKNOWN") - logger.error(f"컨테이너 에러: {message} (상태: {status})") - else: - logger.error(f"API 에러: {message}") -``` - ---- - -### 6단계: main_ori.py 수정 - -**파일**: `poc/instagram/main_ori.py` - -**변경 전** (line 271-274): -```python -from poc.instagram.exceptions import ( - AuthenticationError, - InstagramAPIError, - RateLimitError, -) -``` - -**변경 후**: -```python -from poc.instagram import ErrorState, parse_instagram_error -``` - -**예외 처리 수정** (line 289-298): -```python -# 변경 전 -except AuthenticationError as e: - print(f"[성공] AuthenticationError 발생: {e}") -except RateLimitError as e: - print(f"[성공] RateLimitError 발생: {e}") -except InstagramAPIError as e: - print(f"[성공] InstagramAPIError 발생: {e}") - -# 변경 후 -except Exception as e: - error_state, message, extra_info = parse_instagram_error(e) - - match error_state: - case ErrorState.RATE_LIMIT: - print(f"[성공] Rate Limit 에러: {message}") - case ErrorState.AUTH_ERROR: - print(f"[성공] 인증 에러: {message}") - case ErrorState.CONTAINER_TIMEOUT: - print(f"[성공] 타임아웃 에러: {message}") - case ErrorState.CONTAINER_ERROR: - print(f"[성공] 컨테이너 에러: {message}") - case _: - print(f"[성공] API 에러: {message}") -``` - ---- - -### 7단계: exceptions.py 삭제 - -**파일**: `poc/instagram/exceptions.py` - -**작업**: 파일 삭제 - ---- - -## 최종 client.py 구조 - -```python -""" -Instagram Graph API Client -""" - -import asyncio -import logging -import re -import time -from enum import Enum -from typing import Any, Optional - -import httpx - -from .models import ErrorResponse, Media, MediaContainer - -logger = logging.getLogger(__name__) - - -# ============================================================ -# Error State & Parser -# ============================================================ - -class ErrorState(str, Enum): - """Instagram API 에러 상태""" - RATE_LIMIT = "rate_limit" - AUTH_ERROR = "auth_error" - CONTAINER_TIMEOUT = "container_timeout" - CONTAINER_ERROR = "container_error" - API_ERROR = "api_error" - UNKNOWN = "unknown" - - -def parse_instagram_error(e: Exception) -> tuple[ErrorState, str, dict]: - """Instagram 예외를 파싱하여 상태, 메시지, 추가 정보를 반환""" - # ... (구현부) - - -# ============================================================ -# Instagram Client -# ============================================================ - -class InstagramClient: - """Instagram Graph API 비동기 클라이언트""" - # ... (기존 코드) -``` - ---- - -## 에러 메시지 형식 - -| 에러 유형 | 메시지 prefix | ErrorState | 예시 | -|----------|--------------|------------|------| -| Rate Limit | `[RateLimit]` | `RATE_LIMIT` | `[RateLimit] Rate limit 초과 \| retry_after=60s` | -| 인증 에러 | `[InstagramAPI]` + code=190 | `AUTH_ERROR` | `[InstagramAPI] Invalid token \| code=190` | -| API 에러 | `[InstagramAPI]` | `API_ERROR` | `[InstagramAPI] Error \| code=100` | -| 컨테이너 타임아웃 | `[ContainerTimeout]` | `CONTAINER_TIMEOUT` | `[ContainerTimeout] 타임아웃 (300초 초과)` | -| 컨테이너 에러 | `[ContainerStatus]` | `CONTAINER_ERROR` | `[ContainerStatus] 처리 실패: ERROR` | - ---- - -## 작업 체크리스트 - -- [ ] 1단계: client.py 상단에 ErrorState Enum 및 parse_instagram_error 추가 -- [ ] 2단계: client.py import 문 수정 (re, Enum 추가, exceptions import 삭제) -- [ ] 3단계: client.py 예외 발생 코드 6곳 수정 - - [ ] line 159-162: RateLimitError → Exception - - [ ] line 177-186: create_exception_from_error → Exception - - [ ] line 190-191: InstagramAPIError → Exception - - [ ] line 201: InstagramAPIError → Exception - - [ ] line 217-218: ContainerTimeoutError → Exception - - [ ] line 235: ContainerStatusError → Exception -- [ ] 4단계: __init__.py 수정 (ErrorState, parse_instagram_error export) -- [ ] 5단계: main.py 수정 (ErrorState 활용) -- [ ] 6단계: main_ori.py 수정 (ErrorState 활용) -- [ ] 7단계: exceptions.py 파일 삭제 - ---- - -## 사용 예시 - -### 기본 사용법 -```python -from poc.instagram import InstagramClient, ErrorState, parse_instagram_error - -async def publish_video(video_url: str, caption: str): - async with InstagramClient(access_token="TOKEN") as client: - try: - media = await client.publish_video(video_url=video_url, caption=caption) - return {"success": True, "state": "completed", "data": media} - - except Exception as e: - error_state, message, extra_info = parse_instagram_error(e) - return { - "success": False, - "state": error_state.value, - "message": message, - **extra_info - } -``` - -### match-case 활용 (Python 3.10+) -```python -except Exception as e: - error_state, message, extra_info = parse_instagram_error(e) - - match error_state: - case ErrorState.RATE_LIMIT: - retry_after = extra_info.get("retry_after", 60) - await asyncio.sleep(retry_after) - # 재시도 로직... - - case ErrorState.AUTH_ERROR: - # 토큰 갱신 로직... - - case ErrorState.CONTAINER_TIMEOUT: - # 재시도 또는 알림... - - case ErrorState.CONTAINER_ERROR: - # 실패 처리... - - case _: - # 기본 에러 처리... -``` - -### 응답 예시 -```python -# Rate Limit 에러 -{ - "success": False, - "state": "rate_limit", - "message": "API 호출 제한 초과", - "retry_after": 60 -} - -# 인증 에러 -{ - "success": False, - "state": "auth_error", - "message": "인증 실패 (토큰 만료 또는 무효)" -} - -# 컨테이너 타임아웃 -{ - "success": False, - "state": "container_timeout", - "message": "미디어 처리 시간 초과", - "timeout": 300 -} - -# 컨테이너 에러 -{ - "success": False, - "state": "container_error", - "message": "미디어 컨테이너 처리 실패", - "status": "ERROR" -} - -# API 에러 -{ - "success": False, - "state": "api_error", - "message": "Instagram API 오류", - "code": 100 -} -``` - ---- - -## 장점 - -1. **단일 파일 관리**: client.py 하나에서 클라이언트와 에러 처리 모두 관리 -2. **일관된 에러 형식**: ErrorState Enum으로 타입 안전한 에러 구분 -3. **IDE 지원**: 자동완성, 타입 힌트 지원 -4. **파싱 유틸리티**: parse_instagram_error로 에러 메시지에서 정보 추출 -5. **유연한 처리**: match-case 또는 if-elif로 에러 타입별 처리 가능 diff --git a/main.py b/main.py index bac79c5..2017e68 100644 --- a/main.py +++ b/main.py @@ -32,7 +32,7 @@ tags_metadata = [ 1. `GET /user/auth/kakao/login` - 카카오 로그인 URL 획득 2. 사용자를 auth_url로 리다이렉트 → 카카오 로그인 3. 카카오에서 인가 코드(code) 발급 -4. `POST /user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급 +4. `GET /user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급 (카카오 리다이렉트) 5. 이후 API 호출 시 `Authorization: Bearer {access_token}` 헤더 사용 ## 토큰 관리 @@ -178,7 +178,13 @@ tags_metadata = [ ## 주요 기능 -- `GET /sns/instagram/upload/{task_id}` - Instagram 업로드 상태 조회 +- `POST /sns/instagram/upload/{task_id}` - task_id에 해당하는 비디오를 Instagram에 업로드 + +## Instagram 업로드 흐름 + +1. 사용자의 Instagram 계정이 연동되어 있어야 합니다 (Social Account API 참조) +2. task_id에 해당하는 비디오가 생성 완료 상태(result_movie_url 존재)여야 합니다 +3. 업로드 성공 시 Instagram media_id와 permalink 반환 """, }, ] diff --git a/poc/instagram/main.py b/poc/instagram/main.py index c2dc927..f38150a 100644 --- a/poc/instagram/main.py +++ b/poc/instagram/main.py @@ -20,9 +20,15 @@ logging.basicConfig( logger = logging.getLogger(__name__) # 설정 -ACCESS_TOKEN = "IGAAde0ToiLW1BZAFpTaTBVNEJGMksyV25XY01SMzNHU29RRFJmc25hcXJReUtpbVJvTVNaS2ZAESE92NFlNTS1qazNOLVlSRlJuYTZAoTWFtS2tkSGJYblBPZAVdfZAWNfOGkyY0o2TDBSekdIaUd6WjNaUHZAXb1R0M05YdjRTcTNyNAZDZD" +ACCESS_TOKEN = "" -VIDEO_URL = "https://f002.backblazeb2.com/file/creatomate-c8xg3hsxdu/9b1a680b-3481-4b22-94d4-a5cfd3e19f95.mp4" +VIDEO_URL2 = "https://f002.backblazeb2.com/file/creatomate-c8xg3hsxdu/9b1a680b-3481-4b22-94d4-a5cfd3e19f95.mp4" + +VIDEO_URL3 = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/019c1c1c-e311-756d-8635-bfe62898f73e/019c1c1d-1a3e-78c9-819a-a9de16f487c7/video/스테이머뭄.mp4" + +VIDEO_URL1 = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/019c1d13-db76-7bfa-849f-02803d9e39fb/019c1d21-b686-7dee-b04e-97c8ffe99c28/video/스테이 머뭄.mp4" + +VIDEO_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/019c1d13-db76-7bfa-849f-02803d9e39fb/019c1d21-b686-7dee-b04e-97c8ffe99c28/video/28aa6541ddd74c348c5aae730a232454.mp4" VIDEO_CAPTION = "Test video from Instagram POC #test" diff --git a/poc/instagram/main_ori.py b/poc/instagram/main_ori.py deleted file mode 100644 index ea60074..0000000 --- a/poc/instagram/main_ori.py +++ /dev/null @@ -1,330 +0,0 @@ -""" -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 import InstagramClient, ErrorState, parse_instagram_error - - 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 Exception as e: - error_state, message, extra_info = parse_instagram_error(e) - - match error_state: - case ErrorState.RATE_LIMIT: - retry_after = extra_info.get("retry_after", 60) - print(f"[성공] Rate Limit 에러: {message}") - print(f" 재시도 대기 시간: {retry_after}초") - case ErrorState.AUTH_ERROR: - print(f"[성공] 인증 에러: {message}") - case ErrorState.CONTAINER_TIMEOUT: - print(f"[성공] 타임아웃 에러: {message}") - case ErrorState.CONTAINER_ERROR: - status = extra_info.get("status", "UNKNOWN") - print(f"[성공] 컨테이너 에러: {message} (상태: {status})") - case _: - code = extra_info.get("code") - print(f"[성공] API 에러: {message}") - if code: - print(f" 코드: {code}") - - -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()) diff --git a/poc/instagram/manual.md b/poc/instagram/manual.md deleted file mode 100644 index 761eca6..0000000 --- a/poc/instagram/manual.md +++ /dev/null @@ -1,782 +0,0 @@ -# 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/) diff --git a/poc/instagram/poc.md b/poc/instagram/poc.md deleted file mode 100644 index 4e947eb..0000000 --- a/poc/instagram/poc.md +++ /dev/null @@ -1,266 +0,0 @@ -# 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/) diff --git a/poc/instagram1-difi/DESIGN.md b/poc/instagram1-difi/DESIGN.md deleted file mode 100644 index 85062d5..0000000 --- a/poc/instagram1-difi/DESIGN.md +++ /dev/null @@ -1,817 +0,0 @@ -# Instagram Graph API POC 설계 문서 - -## 📋 1. 요구사항 요약 - -### 1.1 기능적 요구사항 - -| 기능 | 설명 | 우선순위 | -|------|------|----------| -| 인증 | Access Token 관리, Long-lived Token 교환, Token 검증 | 필수 | -| 계정 정보 | 비즈니스 계정 ID 조회, 프로필 정보 조회 | 필수 | -| 미디어 관리 | 목록/상세 조회, 이미지/비디오 게시 (Container → Publish) | 필수 | -| 인사이트 | 계정/미디어별 인사이트 조회 | 필수 | -| 댓글 관리 | 댓글 조회, 답글 작성 | 필수 | - -### 1.2 비기능적 요구사항 - -- **비동기 처리**: 모든 API 호출은 async/await 사용 -- **Rate Limit 준수**: 시간당 200 요청 제한, 429 에러 시 지수 백오프 -- **에러 처리**: 체계적인 예외 계층 구조 -- **로깅**: 요청/응답 추적 가능 -- **보안**: Credentials 환경변수 관리, 민감정보 로깅 방지 - ---- - -## 📐 2. 설계 개요 - -### 2.1 아키텍처 다이어그램 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ examples/ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ auth_example │ │media_example │ │insights_example│ │ -│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ -└─────────┼────────────────┼────────────────┼─────────────────────┘ - │ │ │ - ▼ ▼ ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ InstagramGraphClient │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ - _request() : 공통 HTTP 요청 (재시도, 로깅) │ │ -│ │ - debug_token() : 토큰 검증 │ │ -│ │ - exchange_token() : Long-lived 토큰 교환 │ │ -│ │ - get_account() : 계정 정보 조회 │ │ -│ │ - get_media_list() : 미디어 목록 │ │ -│ │ - get_media() : 미디어 상세 │ │ -│ │ - publish_image() : 이미지 게시 │ │ -│ │ - publish_video() : 비디오 게시 │ │ -│ │ - get_account_insights() : 계정 인사이트 │ │ -│ │ - get_media_insights() : 미디어 인사이트 │ │ -│ │ - get_comments() : 댓글 조회 │ │ -│ │ - reply_comment() : 댓글 답글 │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ models.py │ │ exceptions.py │ │ config.py │ -│ (Pydantic v2) │ │ (커스텀 예외) │ │ (Settings) │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ -``` - -### 2.2 모듈 의존성 관계 - -``` -config.py ◄─────────────────────────────────────┐ - │ │ - ▼ │ -exceptions.py ◄─────────────────────┐ │ - │ │ │ - ▼ │ │ -models.py ◄──────────────┐ │ │ - │ │ │ │ - ▼ │ │ │ -client.py ────────────────┴──────────┴───────────┘ - │ - ▼ -examples/ (사용 예제) -``` - ---- - -## 🌐 3. API 설계 (Instagram Graph API 엔드포인트) - -### 3.1 Base URL -``` -https://graph.instagram.com (Instagram Platform API) -https://graph.facebook.com/v21.0 (Facebook Graph API - 일부 기능) -``` - -### 3.2 인증 API - -#### 3.2.1 토큰 검증 (Debug Token) -``` -GET /debug_token -?input_token={access_token} -&access_token={app_id}|{app_secret} - -Response: -{ - "data": { - "app_id": "123456789", - "type": "USER", - "application": "App Name", - "expires_at": 1234567890, - "is_valid": true, - "scopes": ["instagram_basic", "instagram_content_publish"], - "user_id": "17841400000000000" - } -} -``` - -#### 3.2.2 Long-lived Token 교환 -``` -GET /access_token -?grant_type=ig_exchange_token -&client_secret={app_secret} -&access_token={short_lived_token} - -Response: -{ - "access_token": "IGQVJ...", - "token_type": "bearer", - "expires_in": 5184000 // 60일 (초) -} -``` - -#### 3.2.3 토큰 갱신 -``` -GET /refresh_access_token -?grant_type=ig_refresh_token -&access_token={long_lived_token} - -Response: -{ - "access_token": "IGQVJ...", - "token_type": "bearer", - "expires_in": 5184000 -} -``` - -### 3.3 계정 API - -#### 3.3.1 계정 정보 조회 -``` -GET /me -?fields=id,username,name,account_type,profile_picture_url,followers_count,follows_count,media_count -&access_token={access_token} - -Response: -{ - "id": "17841400000000000", - "username": "example_user", - "name": "Example User", - "account_type": "BUSINESS", - "profile_picture_url": "https://...", - "followers_count": 1000, - "follows_count": 500, - "media_count": 100 -} -``` - -### 3.4 미디어 API - -#### 3.4.1 미디어 목록 조회 -``` -GET /{ig-user-id}/media -?fields=id,media_type,media_url,thumbnail_url,caption,timestamp,permalink,like_count,comments_count -&limit=25 -&access_token={access_token} - -Response: -{ - "data": [ - { - "id": "17880000000000000", - "media_type": "IMAGE", - "media_url": "https://...", - "caption": "My photo", - "timestamp": "2024-01-01T00:00:00+0000", - "permalink": "https://www.instagram.com/p/...", - "like_count": 100, - "comments_count": 10 - } - ], - "paging": { - "cursors": { - "before": "...", - "after": "..." - }, - "next": "https://graph.instagram.com/..." - } -} -``` - -#### 3.4.2 미디어 상세 조회 -``` -GET /{ig-media-id} -?fields=id,media_type,media_url,thumbnail_url,caption,timestamp,permalink,like_count,comments_count,children{id,media_type,media_url} -&access_token={access_token} -``` - -#### 3.4.3 이미지 게시 (2단계 프로세스) - -**Step 1: Container 생성** -``` -POST /{ig-user-id}/media -?image_url={public_image_url} -&caption={caption_text} -&access_token={access_token} - -Response: -{ - "id": "17889000000000000" // container_id -} -``` - -**Step 2: Container 상태 확인** -``` -GET /{container-id} -?fields=status_code,status -&access_token={access_token} - -Response: -{ - "status_code": "FINISHED", // IN_PROGRESS, FINISHED, ERROR - "id": "17889000000000000" -} -``` - -**Step 3: 게시** -``` -POST /{ig-user-id}/media_publish -?creation_id={container_id} -&access_token={access_token} - -Response: -{ - "id": "17880000000000001" // 게시된 media_id -} -``` - -#### 3.4.4 비디오 게시 (3단계 프로세스) - -**Step 1: Container 생성** -``` -POST /{ig-user-id}/media -?media_type=REELS -&video_url={public_video_url} -&caption={caption_text} -&share_to_feed=true -&access_token={access_token} - -Response: -{ - "id": "17889000000000000" -} -``` - -**Step 2 & 3**: 이미지와 동일 (상태 확인 → 게시) - -### 3.5 인사이트 API - -#### 3.5.1 계정 인사이트 -``` -GET /{ig-user-id}/insights -?metric=impressions,reach,profile_views,accounts_engaged -&period=day -&metric_type=total_value -&access_token={access_token} - -Response: -{ - "data": [ - { - "name": "impressions", - "period": "day", - "values": [{"value": 1000}], - "title": "Impressions", - "description": "Total number of times..." - } - ] -} -``` - -#### 3.5.2 미디어 인사이트 -``` -GET /{ig-media-id}/insights -?metric=impressions,reach,engagement,saved -&access_token={access_token} - -Response: -{ - "data": [ - { - "name": "impressions", - "period": "lifetime", - "values": [{"value": 500}], - "title": "Impressions" - } - ] -} -``` - -### 3.6 댓글 API - -#### 3.6.1 댓글 목록 조회 -``` -GET /{ig-media-id}/comments -?fields=id,text,username,timestamp,like_count,replies{id,text,username,timestamp} -&access_token={access_token} - -Response: -{ - "data": [ - { - "id": "17890000000000000", - "text": "Great photo!", - "username": "commenter", - "timestamp": "2024-01-01T12:00:00+0000", - "like_count": 5, - "replies": { - "data": [...] - } - } - ] -} -``` - -#### 3.6.2 댓글 답글 작성 -``` -POST /{ig-comment-id}/replies -?message={reply_text} -&access_token={access_token} - -Response: -{ - "id": "17890000000000001" -} -``` - ---- - -## 📦 4. 데이터 모델 (Pydantic v2) - -### 4.1 인증 모델 - -```python -class TokenInfo(BaseModel): - """토큰 정보""" - access_token: str - token_type: str = "bearer" - expires_in: int # 초 단위 - -class TokenDebugData(BaseModel): - """토큰 디버그 정보""" - app_id: str - type: str - application: str - expires_at: int # Unix timestamp - is_valid: bool - scopes: list[str] - user_id: str - -class TokenDebugResponse(BaseModel): - """토큰 디버그 응답""" - data: TokenDebugData -``` - -### 4.2 계정 모델 - -```python -class Account(BaseModel): - """Instagram 비즈니스 계정""" - id: str - username: str - name: Optional[str] = None - account_type: str # BUSINESS, CREATOR - profile_picture_url: Optional[str] = None - followers_count: int = 0 - follows_count: int = 0 - media_count: int = 0 - biography: Optional[str] = None - website: Optional[str] = None -``` - -### 4.3 미디어 모델 - -```python -class MediaType(str, Enum): - """미디어 타입""" - IMAGE = "IMAGE" - VIDEO = "VIDEO" - CAROUSEL_ALBUM = "CAROUSEL_ALBUM" - REELS = "REELS" - -class Media(BaseModel): - """미디어 정보""" - id: str - media_type: MediaType - media_url: Optional[str] = None - thumbnail_url: Optional[str] = None - caption: Optional[str] = None - timestamp: datetime - permalink: str - like_count: int = 0 - comments_count: int = 0 - children: Optional[list["Media"]] = None # 캐러셀용 - -class MediaContainer(BaseModel): - """미디어 컨테이너 (게시 전 상태)""" - id: str - status_code: Optional[str] = None # IN_PROGRESS, FINISHED, ERROR - status: Optional[str] = None - -class MediaList(BaseModel): - """미디어 목록 응답""" - data: list[Media] - paging: Optional[Paging] = None - -class Paging(BaseModel): - """페이징 정보""" - cursors: Optional[dict[str, str]] = None - next: Optional[str] = None - previous: Optional[str] = None -``` - -### 4.4 인사이트 모델 - -```python -class InsightValue(BaseModel): - """인사이트 값""" - value: int - end_time: Optional[datetime] = None - -class Insight(BaseModel): - """인사이트 정보""" - name: str - period: str # day, week, days_28, lifetime - values: list[InsightValue] - title: str - description: Optional[str] = None - id: str - -class InsightResponse(BaseModel): - """인사이트 응답""" - data: list[Insight] -``` - -### 4.5 댓글 모델 - -```python -class Comment(BaseModel): - """댓글 정보""" - id: str - text: str - username: str - timestamp: datetime - like_count: int = 0 - replies: Optional["CommentList"] = None - -class CommentList(BaseModel): - """댓글 목록 응답""" - data: list[Comment] - paging: Optional[Paging] = None -``` - -### 4.6 에러 모델 - -```python -class APIError(BaseModel): - """Instagram API 에러 응답""" - message: str - type: str - code: int - error_subcode: Optional[int] = None - fbtrace_id: Optional[str] = None - -class ErrorResponse(BaseModel): - """에러 응답 래퍼""" - error: APIError -``` - ---- - -## 🚨 5. 예외 처리 전략 - -### 5.1 예외 계층 구조 - -```python -class InstagramAPIError(Exception): - """Instagram API 기본 예외""" - def __init__(self, message: str, code: int = None, subcode: int = None): - self.message = message - self.code = code - self.subcode = subcode - super().__init__(self.message) - -class AuthenticationError(InstagramAPIError): - """인증 관련 에러 (토큰 만료, 무효 등)""" - # code: 190 (Invalid OAuth access token) - pass - -class RateLimitError(InstagramAPIError): - """Rate Limit 초과 (HTTP 429)""" - def __init__(self, message: str, retry_after: int = None): - super().__init__(message, code=4) - self.retry_after = retry_after - -class PermissionError(InstagramAPIError): - """권한 부족 에러""" - # code: 10 (Permission denied) - # code: 200 (Requires business account) - pass - -class MediaPublishError(InstagramAPIError): - """미디어 게시 실패""" - # 이미지 URL 접근 불가, 포맷 오류 등 - pass - -class InvalidRequestError(InstagramAPIError): - """잘못된 요청 (파라미터 오류 등)""" - # code: 100 (Invalid parameter) - pass - -class ResourceNotFoundError(InstagramAPIError): - """리소스를 찾을 수 없음""" - # code: 803 (Object does not exist) - pass -``` - -### 5.2 에러 코드 매핑 - -| API Error Code | Subcode | Exception Class | 설명 | -|----------------|---------|-----------------|------| -| 4 | - | RateLimitError | Rate limit 초과 | -| 10 | - | PermissionError | 권한 부족 | -| 100 | - | InvalidRequestError | 잘못된 파라미터 | -| 190 | 458 | AuthenticationError | 앱 권한 없음 | -| 190 | 463 | AuthenticationError | 토큰 만료 | -| 190 | 467 | AuthenticationError | 유효하지 않은 토큰 | -| 200 | - | PermissionError | 비즈니스 계정 필요 | -| 803 | - | ResourceNotFoundError | 리소스 없음 | - -### 5.3 재시도 전략 - -```python -RETRY_CONFIG = { - "max_retries": 3, - "base_delay": 1.0, # 초 - "max_delay": 60.0, # 초 - "exponential_base": 2, - "retryable_status_codes": [429, 500, 502, 503, 504], - "retryable_error_codes": [4, 17, 341], # Rate limit 관련 -} -``` - ---- - -## 🔧 6. 클라이언트 인터페이스 - -### 6.1 InstagramGraphClient 클래스 - -```python -class InstagramGraphClient: - """Instagram Graph API 클라이언트""" - - def __init__( - self, - access_token: str, - app_id: Optional[str] = None, - app_secret: Optional[str] = None, - api_version: str = "v21.0", - timeout: float = 30.0, - ): - """ - Args: - access_token: Instagram 액세스 토큰 - app_id: Facebook 앱 ID (토큰 검증 시 필요) - app_secret: Facebook 앱 시크릿 (토큰 교환 시 필요) - api_version: Graph API 버전 - timeout: HTTP 요청 타임아웃 (초) - """ - pass - - async def __aenter__(self) -> "InstagramGraphClient": - """비동기 컨텍스트 매니저 진입""" - pass - - async def __aexit__(self, *args) -> None: - """비동기 컨텍스트 매니저 종료""" - pass - - # ==================== 인증 ==================== - - async def debug_token(self) -> TokenDebugResponse: - """현재 토큰 정보 조회 (유효성 검증)""" - pass - - async def exchange_long_lived_token(self) -> TokenInfo: - """단기 토큰을 장기 토큰(60일)으로 교환""" - pass - - async def refresh_token(self) -> TokenInfo: - """장기 토큰 갱신""" - pass - - # ==================== 계정 ==================== - - async def get_account(self) -> Account: - """현재 계정 정보 조회""" - pass - - async def get_account_id(self) -> str: - """현재 계정 ID만 조회""" - pass - - # ==================== 미디어 ==================== - - async def get_media_list( - self, - limit: int = 25, - after: Optional[str] = None, - ) -> MediaList: - """미디어 목록 조회 (페이지네이션 지원)""" - pass - - async def get_media(self, media_id: str) -> Media: - """미디어 상세 조회""" - pass - - async def publish_image( - self, - image_url: str, - caption: Optional[str] = None, - ) -> Media: - """이미지 게시 (Container 생성 → 상태 확인 → 게시)""" - pass - - async def publish_video( - self, - video_url: str, - caption: Optional[str] = None, - share_to_feed: bool = True, - ) -> Media: - """비디오/릴스 게시""" - pass - - async def publish_carousel( - self, - media_urls: list[str], - caption: Optional[str] = None, - ) -> Media: - """캐러셀(멀티 이미지) 게시""" - pass - - # ==================== 인사이트 ==================== - - async def get_account_insights( - self, - metrics: list[str], - period: str = "day", - ) -> InsightResponse: - """계정 인사이트 조회 - - Args: - metrics: 조회할 메트릭 목록 - - impressions, reach, profile_views, accounts_engaged 등 - period: 기간 (day, week, days_28) - """ - pass - - async def get_media_insights( - self, - media_id: str, - metrics: Optional[list[str]] = None, - ) -> InsightResponse: - """미디어 인사이트 조회 - - Args: - media_id: 미디어 ID - metrics: 조회할 메트릭 (기본: impressions, reach, engagement, saved) - """ - pass - - # ==================== 댓글 ==================== - - async def get_comments( - self, - media_id: str, - limit: int = 50, - ) -> CommentList: - """미디어의 댓글 목록 조회""" - pass - - async def reply_comment( - self, - comment_id: str, - message: str, - ) -> Comment: - """댓글에 답글 작성""" - pass - - # ==================== 내부 메서드 ==================== - - async def _request( - self, - method: str, - endpoint: str, - params: Optional[dict] = None, - data: Optional[dict] = None, - ) -> dict: - """ - 공통 HTTP 요청 처리 - - Rate Limit 시 지수 백오프 재시도 - - 에러 응답 → 커스텀 예외 변환 - - 요청/응답 로깅 - """ - pass - - async def _wait_for_container( - self, - container_id: str, - timeout: float = 60.0, - poll_interval: float = 2.0, - ) -> MediaContainer: - """컨테이너 상태가 FINISHED가 될 때까지 대기""" - pass -``` - ---- - -## 📁 7. 파일 구조 - -``` -poc/instagram/ -├── __init__.py # 패키지 초기화 및 public API export -├── config.py # Settings (환경변수 관리) -├── exceptions.py # 커스텀 예외 클래스 -├── models.py # Pydantic v2 데이터 모델 -├── client.py # InstagramGraphClient -├── examples/ -│ ├── __init__.py -│ ├── auth_example.py # 토큰 검증, 교환 예제 -│ ├── account_example.py # 계정 정보 조회 예제 -│ ├── media_example.py # 미디어 조회/게시 예제 -│ ├── insights_example.py # 인사이트 조회 예제 -│ └── comments_example.py # 댓글 조회/답글 예제 -├── DESIGN.md # 설계 문서 (본 문서) -└── README.md # 사용 가이드 -``` - -### 7.1 각 파일의 역할 - -| 파일 | 역할 | 의존성 | -|------|------|--------| -| `config.py` | 환경변수 로드, API 설정 | pydantic-settings | -| `exceptions.py` | 커스텀 예외 정의 | - | -| `models.py` | API 요청/응답 Pydantic 모델 | pydantic | -| `client.py` | Instagram Graph API 클라이언트 | httpx, 위 모듈들 | -| `examples/*.py` | 실행 가능한 예제 코드 | client.py | - ---- - -## 📋 8. 구현 순서 - -개발 에이전트가 따라야 할 순서: - -### Phase 1: 기반 모듈 (의존성 없음) -1. `config.py` - Settings 클래스 -2. `exceptions.py` - 예외 클래스 계층 - -### Phase 2: 데이터 모델 -3. `models.py` - Pydantic 모델 (Token, Account, Media, Insight, Comment) - -### Phase 3: 클라이언트 구현 -4. `client.py` - InstagramGraphClient - - 4.1: 기본 구조 및 `_request()` 메서드 - - 4.2: 인증 메서드 (debug_token, exchange_token, refresh_token) - - 4.3: 계정 메서드 (get_account, get_account_id) - - 4.4: 미디어 메서드 (get_media_list, get_media, publish_image, publish_video) - - 4.5: 인사이트 메서드 (get_account_insights, get_media_insights) - - 4.6: 댓글 메서드 (get_comments, reply_comment) - -### Phase 4: 예제 및 문서 -5. `examples/auth_example.py` -6. `examples/account_example.py` -7. `examples/media_example.py` -8. `examples/insights_example.py` -9. `examples/comments_example.py` -10. `README.md` - ---- - -## ✅ 9. 설계 검수 결과 - -### 검수 체크리스트 - -- [x] **기존 프로젝트 패턴과 일관성** - 3계층 구조, Pydantic v2, 비동기 패턴 적용 -- [x] **비동기 처리** - httpx.AsyncClient, async/await 전체 적용 -- [x] **N+1 쿼리 문제** - 해당 없음 (외부 API 호출) -- [x] **트랜잭션 경계** - 해당 없음 (DB 미사용) -- [x] **예외 처리 전략** - 계층화된 예외, 에러 코드 매핑, 재시도 로직 -- [x] **확장성** - 새 엔드포인트 추가 용이, 모델 확장 가능 -- [x] **직관적 구조** - 명확한 모듈 분리, 일관된 네이밍 -- [x] **SOLID 원칙** - 단일 책임, 개방-폐쇄 원칙 준수 - -### 참고 문서 - -- [Instagram Graph API 공식 가이드](https://elfsight.com/blog/instagram-graph-api-complete-developer-guide-for-2025/) -- [Instagram Platform API 구현 가이드](https://gist.github.com/PrenSJ2/0213e60e834e66b7e09f7f93999163fc) - ---- - -## 🔄 다음 단계 - -설계가 완료되었습니다. `/develop` 명령으로 개발 에이전트를 호출하여 구현을 진행합니다. diff --git a/poc/instagram1-difi/DESIGN_V2.md b/poc/instagram1-difi/DESIGN_V2.md deleted file mode 100644 index 17a855d..0000000 --- a/poc/instagram1-difi/DESIGN_V2.md +++ /dev/null @@ -1,343 +0,0 @@ -# Instagram Graph API POC - 리팩토링 설계 문서 (V2) - -**작성일**: 2026-01-29 -**기반**: REVIEW_V1.md 분석 결과 - ---- - -## 1. 리뷰 기반 개선 요약 - -### Critical 이슈 (3건) - -| 이슈 | 현재 상태 | 개선 방향 | -|------|----------|----------| -| `PermissionError` 섀도잉 | Python 내장 예외와 이름 충돌 | `InstagramPermissionError`로 변경 | -| 토큰 노출 위험 | 로그에 토큰이 그대로 기록 | 마스킹 유틸리티 추가 | -| 싱글톤 설정 | 테스트 시 모킹 어려움 | 팩토리 패턴 적용 | - -### Major 이슈 (6건) - -| 이슈 | 현재 상태 | 개선 방향 | -|------|----------|----------| -| 시간 계산 정확도 | `elapsed += interval` | `time.monotonic()` 사용 | -| 타임존 미처리 | naive datetime | UTC 명시적 처리 | -| 캐러셀 순차 처리 | 이미지별 직렬 생성 | `asyncio.gather()` 병렬화 | -| 생성자 예외 | `__init__`에서 예외 발생 | validate 메서드 분리 | -| 서브코드 미활용 | code만 매핑 | (code, subcode) 튜플 매핑 | -| JSON 파싱 에러 | 무조건 파싱 시도 | try-except 처리 | - ---- - -## 2. 개선 설계 - -### 2.1 예외 클래스 이름 변경 - -```python -# exceptions.py - 변경 전 -class PermissionError(InstagramAPIError): ... - -# exceptions.py - 변경 후 -class InstagramPermissionError(InstagramAPIError): - """ - 권한 부족 에러 - - Python 내장 PermissionError와 구분하기 위해 접두사 사용 - """ - pass - -# 하위 호환성을 위한 alias (deprecated) -PermissionError = InstagramPermissionError # deprecated alias -``` - -**영향 범위**: -- `exceptions.py`: 클래스명 변경 -- `__init__.py`: export 업데이트 -- `client.py`: import 업데이트 - -### 2.2 토큰 마스킹 유틸리티 - -```python -# client.py에 추가 -def _mask_sensitive_params(self, params: dict[str, Any]) -> dict[str, Any]: - """ - 로깅용 파라미터에서 민감 정보 마스킹 - - Args: - params: 원본 파라미터 - - Returns: - 마스킹된 파라미터 복사본 - """ - SENSITIVE_KEYS = {"access_token", "client_secret", "input_token"} - masked = params.copy() - - for key in SENSITIVE_KEYS: - if key in masked and masked[key]: - value = str(masked[key]) - if len(value) > 14: - masked[key] = f"{value[:10]}...{value[-4:]}" - else: - masked[key] = "***" - - return masked -``` - -**적용 위치**: -- `_request()` 메서드의 디버그 로깅 - -### 2.3 설정 팩토리 패턴 - -```python -# config.py - 변경 -from functools import lru_cache - -class InstagramSettings(BaseSettings): - # ... 기존 코드 유지 ... - pass - -@lru_cache() -def get_settings() -> InstagramSettings: - """ - 설정 인스턴스 반환 (캐싱됨) - - 테스트 시 캐시 초기화: - get_settings.cache_clear() - """ - return InstagramSettings() - -# 하위 호환성을 위한 기본 인스턴스 -# @deprecated: get_settings() 사용 권장 -settings = get_settings() -``` - -**장점**: -- 테스트 시 `get_settings.cache_clear()` 호출로 모킹 가능 -- 기존 코드 호환성 유지 - -### 2.4 시간 계산 정확도 개선 - -```python -# client.py - _wait_for_container 메서드 개선 -import time - -async def _wait_for_container( - self, - container_id: str, - timeout: Optional[float] = None, - poll_interval: Optional[float] = None, -) -> MediaContainer: - timeout = timeout or self.settings.container_timeout - poll_interval = poll_interval or self.settings.container_poll_interval - - start_time = time.monotonic() # 변경: 정확한 시간 측정 - - while True: - elapsed = time.monotonic() - start_time - if elapsed >= timeout: - raise ContainerTimeoutError( - f"컨테이너 처리 타임아웃 ({timeout}초 초과): {container_id}" - ) - - # ... 상태 확인 로직 ... - - await asyncio.sleep(poll_interval) -``` - -### 2.5 타임존 처리 - -```python -# models.py - TokenDebugData 개선 -from datetime import datetime, timezone - -class TokenDebugData(BaseModel): - # ... 기존 필드 ... - - @property - def expires_at_datetime(self) -> datetime: - """만료 시각을 UTC datetime으로 변환""" - return datetime.fromtimestamp(self.expires_at, tz=timezone.utc) - - @property - def is_expired(self) -> bool: - """토큰 만료 여부 확인 (UTC 기준)""" - return datetime.now(timezone.utc).timestamp() > self.expires_at -``` - -### 2.6 캐러셀 병렬 처리 - -```python -# client.py - publish_carousel 개선 -async def publish_carousel( - self, - media_urls: list[str], - caption: Optional[str] = None, -) -> Media: - if len(media_urls) < 2 or len(media_urls) > 10: - raise ValueError("캐러셀은 2-10개의 이미지가 필요합니다.") - - account_id = await self.get_account_id() - - # Step 1: 각 이미지의 Container 병렬 생성 - async def create_item_container(url: str) -> str: - container_url = self.settings.get_instagram_url(f"{account_id}/media") - response = await self._request( - method="POST", - url=container_url, - params={"image_url": url, "is_carousel_item": "true"}, - ) - return response["id"] - - logger.debug(f"[publish_carousel] Step 1: {len(media_urls)}개 Container 병렬 생성") - children_ids = await asyncio.gather( - *[create_item_container(url) for url in media_urls] - ) - - # ... 이후 동일 ... -``` - -### 2.7 에러 코드 매핑 확장 - -```python -# exceptions.py - 서브코드 매핑 추가 -# 기본 코드 매핑 -ERROR_CODE_MAPPING: dict[int, type[InstagramAPIError]] = { - 4: RateLimitError, - 10: InstagramPermissionError, - 17: RateLimitError, - 100: InvalidRequestError, - 190: AuthenticationError, - 200: InstagramPermissionError, - 230: InstagramPermissionError, - 341: RateLimitError, - 803: ResourceNotFoundError, -} - -# (code, subcode) 세부 매핑 -ERROR_CODE_SUBCODE_MAPPING: dict[tuple[int, int], type[InstagramAPIError]] = { - (100, 33): ResourceNotFoundError, # Object does not exist - (190, 458): AuthenticationError, # App not authorized - (190, 463): AuthenticationError, # Token expired - (190, 467): AuthenticationError, # Invalid token -} - -def create_exception_from_error(...) -> InstagramAPIError: - # 먼저 (code, subcode) 조합 확인 - if code is not None and subcode is not None: - key = (code, subcode) - if key in ERROR_CODE_SUBCODE_MAPPING: - exception_class = ERROR_CODE_SUBCODE_MAPPING[key] - return exception_class(...) - - # 기본 코드 매핑 - if code is not None: - exception_class = ERROR_CODE_MAPPING.get(code, InstagramAPIError) - else: - exception_class = InstagramAPIError - - return exception_class(...) -``` - -### 2.8 JSON 파싱 안전 처리 - -```python -# client.py - _request 메서드 개선 -async def _request(...) -> dict[str, Any]: - # ... 기존 코드 ... - - # JSON 파싱 (안전 처리) - try: - response_data = response.json() - except ValueError as e: - logger.error( - f"[_request] JSON 파싱 실패: status={response.status_code}, " - f"body={response.text[:200]}" - ) - raise InstagramAPIError( - f"API 응답 파싱 실패: {e}", - code=response.status_code, - ) - - # ... 이후 동일 ... -``` - ---- - -## 3. 파일 변경 계획 - -| 파일 | 변경 유형 | 변경 내용 | -|------|----------|----------| -| `config.py` | 수정 | 팩토리 패턴 추가 (`get_settings()`) | -| `exceptions.py` | 수정 | `PermissionError` → `InstagramPermissionError`, 서브코드 매핑 추가 | -| `models.py` | 수정 | 타임존 처리 추가 | -| `client.py` | 수정 | 토큰 마스킹, 시간 계산, 병렬 처리, JSON 안전 파싱 | -| `__init__.py` | 수정 | export 업데이트 (`InstagramPermissionError`) | - ---- - -## 4. 구현 순서 - -1. **exceptions.py** 수정 (의존성 없음) - - `InstagramPermissionError` 이름 변경 - - 서브코드 매핑 추가 - - deprecated alias 추가 - -2. **config.py** 수정 - - `get_settings()` 팩토리 함수 추가 - - 기존 `settings` 변수 유지 (하위 호환) - -3. **models.py** 수정 - - `TokenDebugData` 타임존 처리 - -4. **client.py** 수정 - - `_mask_sensitive_params()` 추가 - - `_request()` 로깅 개선 - - `_wait_for_container()` 시간 계산 개선 - - `publish_carousel()` 병렬 처리 - - JSON 파싱 안전 처리 - -5. **__init__.py** 수정 - - `InstagramPermissionError` export 추가 - - `PermissionError` deprecated 표시 - ---- - -## 5. 하위 호환성 보장 - -| 변경 | 호환성 유지 방법 | -|------|-----------------| -| `PermissionError` 이름 | alias로 기존 이름 유지, deprecation 경고 추가 | -| `settings` 싱글톤 | 기존 변수 유지, 새 방식 문서화 | -| 동작 변경 없음 | 내부 최적화만, API 동일 | - ---- - -## 6. 테스트 가능성 개선 - -```python -# 테스트 예시 -import pytest -from poc.instagram1.config import get_settings - -@pytest.fixture -def mock_settings(monkeypatch): - """설정 모킹 fixture""" - monkeypatch.setenv("INSTAGRAM_ACCESS_TOKEN", "test_token") - monkeypatch.setenv("INSTAGRAM_APP_ID", "test_app_id") - get_settings.cache_clear() # 캐시 초기화 - yield get_settings() - get_settings.cache_clear() # 정리 -``` - ---- - -## 7. 다음 단계 - -이 설계를 바탕으로 **Stage 5 (리팩토링 개발)**에서: - -1. 위 순서대로 파일 수정 -2. 각 변경 후 기존 예제 실행 확인 -3. 변경된 부분에 대한 주석/문서 업데이트 - ---- - -*이 설계는 `/design` 에이전트에 의해 자동 생성되었습니다.* diff --git a/poc/instagram1-difi/README.md b/poc/instagram1-difi/README.md deleted file mode 100644 index 6c7c1ea..0000000 --- a/poc/instagram1-difi/README.md +++ /dev/null @@ -1,199 +0,0 @@ -# Instagram Graph API POC - -Instagram Graph API를 활용한 비즈니스/크리에이터 계정 관리 POC입니다. - -## 기능 - -- **인증**: 토큰 검증, 장기 토큰 교환, 토큰 갱신 -- **계정**: 프로필 정보 조회 -- **미디어**: 목록/상세 조회, 이미지/비디오/캐러셀 게시 -- **인사이트**: 계정/미디어 성과 지표 조회 -- **댓글**: 댓글 조회, 답글 작성 - -## 요구사항 - -### 1. Instagram 비즈니스/크리에이터 계정 - -개인 계정은 지원하지 않습니다. Instagram 앱에서 비즈니스 또는 크리에이터 계정으로 전환해야 합니다. - -### 2. Facebook 앱 설정 - -1. [Meta for Developers](https://developers.facebook.com/)에서 앱 생성 -2. Instagram Graph API 제품 추가 -3. 필요한 권한 설정: - - `instagram_basic` - 기본 프로필 정보 - - `instagram_content_publish` - 콘텐츠 게시 - - `instagram_manage_comments` - 댓글 관리 - - `instagram_manage_insights` - 인사이트 조회 - -### 3. 액세스 토큰 발급 - -Graph API Explorer 또는 OAuth 흐름을 통해 액세스 토큰을 발급받습니다. - -## 설치 - -```bash -# 프로젝트 루트에서 -uv add httpx pydantic-settings -``` - -## 환경변수 설정 - -`.env` 파일 또는 환경변수로 설정: - -```bash -# 필수 -export INSTAGRAM_ACCESS_TOKEN="your_access_token" - -# 토큰 검증/교환 시 필요 -export INSTAGRAM_APP_ID="your_app_id" -export INSTAGRAM_APP_SECRET="your_app_secret" -``` - -## 사용법 - -### 기본 사용 - -```python -import asyncio -from poc.instagram1 import InstagramGraphClient - -async def main(): - async with InstagramGraphClient() as client: - # 계정 정보 조회 - account = await client.get_account() - print(f"@{account.username}: {account.followers_count} followers") - - # 미디어 목록 조회 - media_list = await client.get_media_list(limit=10) - for media in media_list.data: - print(f"{media.media_type}: {media.like_count} likes") - -asyncio.run(main()) -``` - -### 토큰 검증 - -```python -async with InstagramGraphClient() as client: - token_info = await client.debug_token() - print(f"Valid: {token_info.data.is_valid}") - print(f"Expires: {token_info.data.expires_at_datetime}") -``` - -### 이미지 게시 - -```python -async with InstagramGraphClient() as client: - media = await client.publish_image( - image_url="https://example.com/image.jpg", - caption="My photo! #photography", - ) - print(f"Posted: {media.permalink}") -``` - -### 인사이트 조회 - -```python -async with InstagramGraphClient() as client: - # 계정 인사이트 - insights = await client.get_account_insights( - metrics=["impressions", "reach"], - period="day", - ) - for insight in insights.data: - print(f"{insight.name}: {insight.latest_value}") - - # 미디어 인사이트 - media_insights = await client.get_media_insights("MEDIA_ID") - reach = media_insights.get_metric("reach") - print(f"Reach: {reach.latest_value}") -``` - -### 댓글 관리 - -```python -async with InstagramGraphClient() as client: - # 댓글 조회 - comments = await client.get_comments("MEDIA_ID") - for comment in comments.data: - print(f"@{comment.username}: {comment.text}") - - # 답글 작성 - reply = await client.reply_comment( - comment_id="COMMENT_ID", - message="Thanks!", - ) -``` - -## 예제 실행 - -```bash -# 인증 예제 -python -m poc.instagram1.examples.auth_example - -# 계정 예제 -python -m poc.instagram1.examples.account_example - -# 미디어 예제 -python -m poc.instagram1.examples.media_example - -# 인사이트 예제 -python -m poc.instagram1.examples.insights_example - -# 댓글 예제 -python -m poc.instagram1.examples.comments_example -``` - -## 에러 처리 - -```python -from poc.instagram1 import ( - InstagramGraphClient, - AuthenticationError, - RateLimitError, - InstagramPermissionError, - MediaPublishError, -) - -async with InstagramGraphClient() as client: - try: - account = await client.get_account() - except AuthenticationError as e: - print(f"토큰 오류: {e}") - except RateLimitError as e: - print(f"Rate limit 초과. {e.retry_after}초 후 재시도") - except InstagramPermissionError as e: - print(f"권한 부족: {e}") -``` - -## Rate Limit - -- 시간당 200회 요청 제한 (사용자 토큰당) -- 429 응답 시 자동으로 지수 백오프 재시도 -- `RateLimitError.retry_after`로 대기 시간 확인 가능 - -## 파일 구조 - -``` -poc/instagram1/ -├── __init__.py # 패키지 진입점 -├── config.py # 설정 (환경변수) -├── exceptions.py # 커스텀 예외 -├── models.py # Pydantic 모델 -├── client.py # API 클라이언트 -├── examples/ # 실행 예제 -│ ├── auth_example.py -│ ├── account_example.py -│ ├── media_example.py -│ ├── insights_example.py -│ └── comments_example.py -├── DESIGN.md # 설계 문서 -└── README.md # 사용 가이드 (본 문서) -``` - -## 참고 문서 - -- [Instagram Graph API 공식 문서](https://developers.facebook.com/docs/instagram-api) -- [Instagram Platform API 가이드](https://developers.facebook.com/docs/instagram-platform) -- [Graph API Explorer](https://developers.facebook.com/tools/explorer/) diff --git a/poc/instagram1-difi/REVIEW_FINAL.md b/poc/instagram1-difi/REVIEW_FINAL.md deleted file mode 100644 index d9e444b..0000000 --- a/poc/instagram1-difi/REVIEW_FINAL.md +++ /dev/null @@ -1,224 +0,0 @@ -# Instagram Graph API POC - 최종 코드 리뷰 리포트 - -**리뷰 일시**: 2026-01-29 -**리뷰 대상**: `poc/instagram/` 리팩토링 완료 코드 - ---- - -## 1. 리뷰 요약 - -### V1 리뷰에서 지적된 이슈 해결 현황 - -| 심각도 | 이슈 | 상태 | 비고 | -|--------|------|------|------| -| 🔴 Critical | `PermissionError` 내장 예외 섀도잉 | ✅ 해결 | `InstagramPermissionError`로 변경 | -| 🔴 Critical | 토큰 노출 위험 | ✅ 해결 | `_mask_sensitive_params()` 추가 | -| 🔴 Critical | 싱글톤 설정 테스트 어려움 | ✅ 해결 | `get_settings()` 팩토리 패턴 | -| 🟡 Warning | 시간 계산 정확도 | ✅ 해결 | `time.monotonic()` 사용 | -| 🟡 Warning | 타임존 미처리 | ✅ 해결 | `timezone.utc` 명시 | -| 🟡 Warning | 캐러셀 순차 처리 | ✅ 해결 | `asyncio.gather()` 병렬화 | -| 🟡 Warning | 서브코드 미활용 | ✅ 해결 | `ERROR_CODE_SUBCODE_MAPPING` 추가 | -| 🟡 Warning | JSON 파싱 에러 | ✅ 해결 | try-except 안전 처리 | - ---- - -## 2. 개선 상세 - -### 2.1 예외 클래스 이름 변경 ✅ - -**변경 전** ([exceptions.py:105](poc/instagram/exceptions.py#L105)): -```python -class PermissionError(InstagramAPIError): ... # Python 내장 예외와 충돌 -``` - -**변경 후**: -```python -class InstagramPermissionError(InstagramAPIError): - """Python 내장 PermissionError와 구분하기 위해 접두사 사용""" - pass - -# 하위 호환성 alias -PermissionError = InstagramPermissionError -``` - -### 2.2 토큰 마스킹 ✅ - -**추가** ([client.py:120-141](poc/instagram/client.py#L120-L141)): -```python -def _mask_sensitive_params(self, params: dict[str, Any]) -> dict[str, Any]: - SENSITIVE_KEYS = {"access_token", "client_secret", "input_token"} - masked = params.copy() - for key in SENSITIVE_KEYS: - if key in masked and masked[key]: - value = str(masked[key]) - if len(value) > 14: - masked[key] = f"{value[:10]}...{value[-4:]}" - else: - masked[key] = "***" - return masked -``` - -### 2.3 설정 팩토리 패턴 ✅ - -**추가** ([config.py:121-140](poc/instagram/config.py#L121-L140)): -```python -from functools import lru_cache - -@lru_cache() -def get_settings() -> InstagramSettings: - """테스트 시 cache_clear()로 초기화 가능""" - return InstagramSettings() - -# 하위 호환성 유지 -settings = get_settings() -``` - -### 2.4 시간 계산 정확도 ✅ - -**변경** ([client.py:296](poc/instagram/client.py#L296)): -```python -# 변경 전: elapsed += poll_interval -# 변경 후: -start_time = time.monotonic() -while True: - elapsed = time.monotonic() - start_time - if elapsed >= timeout: - break - # ... -``` - -### 2.5 타임존 처리 ✅ - -**변경** ([models.py:107-114](poc/instagram/models.py#L107-L114)): -```python -@property -def expires_at_datetime(self) -> datetime: - """만료 시각을 UTC datetime으로 변환""" - return datetime.fromtimestamp(self.expires_at, tz=timezone.utc) - -@property -def is_expired(self) -> bool: - """토큰 만료 여부 확인 (UTC 기준)""" - return datetime.now(timezone.utc).timestamp() > self.expires_at -``` - -### 2.6 캐러셀 병렬 처리 ✅ - -**변경** ([client.py:791-814](poc/instagram/client.py#L791-L814)): -```python -async def create_item_container(url: str, index: int) -> str: - # ... - return response["id"] - -# 병렬로 모든 컨테이너 생성 -children_ids = await asyncio.gather( - *[create_item_container(url, i) for i, url in enumerate(media_urls)] -) -``` - -### 2.7 서브코드 매핑 ✅ - -**추가** ([exceptions.py:205-211](poc/instagram/exceptions.py#L205-L211)): -```python -ERROR_CODE_SUBCODE_MAPPING: dict[tuple[int, int], type[InstagramAPIError]] = { - (100, 33): ResourceNotFoundError, - (190, 458): AuthenticationError, - (190, 463): AuthenticationError, - (190, 467): AuthenticationError, -} -``` - -### 2.8 JSON 파싱 안전 처리 ✅ - -**변경** ([client.py:227-238](poc/instagram/client.py#L227-L238)): -```python -try: - response_data = response.json() -except ValueError as e: - logger.error( - f"[_request] JSON 파싱 실패: status={response.status_code}, " - f"body={response.text[:200]}" - ) - raise InstagramAPIError(f"API 응답 파싱 실패: {e}", code=response.status_code) from e -``` - ---- - -## 3. 최종 코드 품질 평가 - -| 항목 | V1 점수 | V2 점수 | 개선 | -|------|---------|---------|------| -| **코드 품질** | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐⭐ | +1 | -| **보안** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | +1 | -| **성능** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | +1 | -| **가독성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 유지 | -| **확장성** | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐⭐ | +1 | -| **테스트 용이성** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | +1 | - -**종합 점수: 4.5 / 5.0** (V1: 3.7 → V2: 4.5, +0.8 향상) - ---- - -## 4. 남은 개선 사항 (Minor) - -아래 항목들은 필수는 아니지만 추후 개선을 고려할 수 있습니다: - -| 항목 | 설명 | 우선순위 | -|------|------|----------| -| 예제 공통 모듈화 | 각 예제의 중복 로깅 설정 분리 | 낮음 | -| API 상수 분리 | `limit` 최대값 등 상수 별도 관리 | 낮음 | -| 모델 예제 일관성 | 모든 모델에 `json_schema_extra` 추가 | 낮음 | -| 비동기 폴링 개선 | 지수 백오프 패턴 적용 | 중간 | - ---- - -## 5. 파일 구조 최종 - -``` -poc/instagram/ -├── __init__.py # 패키지 exports (업데이트됨) -├── config.py # 설정 + get_settings() 팩토리 (업데이트됨) -├── exceptions.py # InstagramPermissionError + 서브코드 매핑 (업데이트됨) -├── models.py # 타임존 처리 추가 (업데이트됨) -├── client.py # 토큰 마스킹, 병렬 처리, 시간 정확도 (업데이트됨) -├── DESIGN.md # 초기 설계 문서 -├── DESIGN_V2.md # 리팩토링 설계 문서 -├── REVIEW_V1.md # 초기 리뷰 리포트 -├── REVIEW_FINAL.md # 최종 리뷰 리포트 (현재 파일) -├── README.md # 사용 가이드 -└── examples/ - ├── __init__.py - ├── auth_example.py - ├── account_example.py - ├── media_example.py - ├── insights_example.py - └── comments_example.py -``` - ---- - -## 6. 결론 - -### 성과 - -1. **모든 Critical 이슈 해결**: 내장 예외 충돌, 보안 위험, 테스트 용이성 문제 모두 해결 -2. **모든 Major 이슈 해결**: 시간 처리, 타임존, 병렬 처리, 에러 처리 개선 -3. **하위 호환성 유지**: alias와 deprecated 표시로 기존 코드 호환 -4. **코드 품질 대폭 향상**: 종합 점수 3.7 → 4.5 - -### POC 완성도 - -Instagram Graph API POC가 프로덕션 수준의 코드 품질을 갖추게 되었습니다: - -- ✅ 완전한 타입 힌트 -- ✅ 상세한 docstring과 예제 -- ✅ 계층화된 예외 처리 -- ✅ Rate Limit 재시도 로직 -- ✅ 비동기 컨텍스트 매니저 -- ✅ Container 기반 미디어 게시 -- ✅ 테스트 가능한 설계 -- ✅ 보안 고려 (토큰 마스킹) - ---- - -*이 리뷰는 `/review` 에이전트에 의해 자동 생성되었습니다.* diff --git a/poc/instagram1-difi/REVIEW_V1.md b/poc/instagram1-difi/REVIEW_V1.md deleted file mode 100644 index 0491147..0000000 --- a/poc/instagram1-difi/REVIEW_V1.md +++ /dev/null @@ -1,198 +0,0 @@ -# Instagram Graph API POC - 코드 리뷰 리포트 (V1) - -**리뷰 일시**: 2026-01-29 -**리뷰 대상**: `poc/instagram/` 초기 구현 - ---- - -## 1. 리뷰 대상 파일 - -| 파일 | 라인 수 | 설명 | -|------|---------|------| -| `config.py` | 123 | 설정 관리 (pydantic-settings) | -| `exceptions.py` | 230 | 커스텀 예외 클래스 | -| `models.py` | 498 | Pydantic v2 데이터 모델 | -| `client.py` | 1000 | 비동기 API 클라이언트 | -| `__init__.py` | 65 | 패키지 익스포트 | -| `examples/*.py` | 5개 | 실행 가능한 예제 | -| `README.md` | - | 사용 가이드 | - ---- - -## 2. 흐름 분석 - -``` -사용자 코드 - │ - ▼ -InstagramGraphClient (context manager) - │ - ├── __aenter__: httpx.AsyncClient 초기화 - │ - ├── API 메서드 호출 - │ │ - │ ▼ - │ _request() ─────────────────────┐ - │ │ │ - │ ├── 토큰 자동 추가 │ - │ ├── 재시도 로직 │◄── Rate Limit (429) - │ ├── 에러 응답 파싱 │◄── Server Error (5xx) - │ └── 예외 변환 │ - │ │ - │ ◄───────────────────────────────┘ - │ - └── __aexit__: 클라이언트 정리 -``` - ---- - -## 3. 검사 결과 - -### 🔴 Critical (즉시 수정 필요) - -| 파일:라인 | 문제 | 설명 | 개선 방안 | -|-----------|------|------|----------| -| `exceptions.py:105` | 내장 예외 섀도잉 | `PermissionError`가 Python 내장 `PermissionError`를 섀도잉함 | `InstagramPermissionError`로 이름 변경 권장 | -| `client.py:152` | 토큰 노출 위험 | 디버그 로그에 요청 URL/파라미터가 기록될 수 있어 토큰 노출 가능성 | 민감 정보 마스킹 로직 추가 | -| `config.py:121-122` | 싱글톤 초기화 시점 | 모듈 로드 시 즉시 Settings 인스턴스 생성으로 테스트 시 환경변수 모킹 어려움 | lazy initialization 또는 팩토리 함수 패턴 적용 | - -### 🟡 Warning (권장 수정) - -| 파일:라인 | 문제 | 설명 | 개선 방안 | -|-----------|------|------|----------| -| `client.py:269-293` | 시간 계산 정확도 | `elapsed += poll_interval`은 실제 sleep 시간과 다를 수 있음 | `time.monotonic()` 기반 계산으로 변경 | -| `models.py:107-114` | 타임존 미처리 | `datetime.now()`가 naive datetime을 반환, `expires_at`은 UTC일 수 있음 | `datetime.now(timezone.utc)` 사용 | -| `client.py:756-768` | 순차 컨테이너 생성 | 캐러셀 이미지 컨테이너를 순차적으로 생성하여 느림 | `asyncio.gather()`로 병렬 처리 | -| `client.py:84-88` | 생성자 예외 | `__init__`에서 예외 발생 시 비동기 리소스 정리 불가 | validate 메서드 분리 또는 팩토리 패턴 | -| `exceptions.py:188-198` | 서브코드 미활용 | ERROR_CODE_MAPPING이 subcode를 고려하지 않음 | (code, subcode) 튜플 기반 매핑 확장 | -| `client.py:204` | 무조건 JSON 파싱 | 비정상 응답 시 JSON 파싱 에러 발생 가능 | try-except로 감싸고 원본 응답 포함 | - -### 🟢 Info (참고 사항) - -| 파일:라인 | 내용 | -|-----------|------| -| `models.py:256-269` | `json_schema_extra` example이 `Media` 모델에만 있음, 일관성 위해 다른 모델에도 추가 고려 | -| `client.py:519-525` | `limit` 파라미터 최대값(100) 상수화 권장 | -| `config.py:59` | API 버전 `v21.0` 하드코딩, 환경변수 오버라이드 가능하지만 업데이트 정책 문서화 필요 | -| `examples/*.py` | 각 예제에서 중복되는 로깅 설정 코드가 있음, 공통 모듈로 분리 가능 | -| `__init__.py` | `__all__` 정의 완전하고 명확함 ✓ | - ---- - -## 4. 성능 분석 - -### 잠재적 성능 이슈 - -1. **캐러셀 게시 병목** (`client.py:756-768`) - - 현재: 이미지별 순차 컨테이너 생성 (N개 이미지 = N번 API 호출 직렬) - - 영향: 10개 이미지 → 최소 10 * RTT 시간 - - 개선: `asyncio.gather()`로 병렬 처리 - -2. **컨테이너 폴링 비효율** (`client.py:239-297`) - - 현재: 고정 간격(2초) 폴링 - - 개선: 지수 백오프 + 최대 간격 패턴 - -3. **계정 ID 캐싱** (`client.py:463-486`) - - 현재: 인스턴스 레벨 캐싱 ✓ (잘 구현됨) - - 참고: 멀티 계정 지원 시 캐시 키 확장 필요 - -### 추천 최적화 - -```python -# 병렬 컨테이너 생성 예시 -async def _create_carousel_containers(self, media_urls: list[str]) -> list[str]: - tasks = [ - self._create_container(url, is_carousel_item=True) - for url in media_urls - ] - return await asyncio.gather(*tasks) -``` - ---- - -## 5. 보안 분석 - -### 확인된 보안 사항 - -| 항목 | 상태 | 설명 | -|------|------|------| -| Credentials 하드코딩 | ✅ 양호 | 환경변수/설정 파일에서 로드 | -| HTTPS 사용 | ✅ 양호 | 모든 API URL이 HTTPS | -| 토큰 전송 | ✅ 양호 | 쿼리 파라미터로 전송 (Graph API 표준) | -| 에러 메시지 노출 | ⚠️ 주의 | `fbtrace_id` 등 디버그 정보 로깅 | -| 로그 내 토큰 | 🔴 위험 | 요청 파라미터 로깅 시 토큰 노출 가능 | - -### 보안 개선 권장 - -```python -# 민감 정보 마스킹 예시 -def _mask_token(self, params: dict) -> dict: - """로깅용 파라미터에서 토큰 마스킹""" - masked = params.copy() - if "access_token" in masked: - token = masked["access_token"] - masked["access_token"] = f"{token[:10]}...{token[-4:]}" - return masked -``` - ---- - -## 6. 전체 평가 - -| 항목 | 점수 | 평가 | -|------|------|------| -| **코드 품질** | ⭐⭐⭐⭐☆ | 타입 힌트 완전, docstring 충실, 구조화 양호 | -| **보안** | ⭐⭐⭐☆☆ | 기본 보안 준수, 로깅 관련 개선 필요 | -| **성능** | ⭐⭐⭐☆☆ | 기본 재시도 로직 있음, 병렬처리 개선 여지 | -| **가독성** | ⭐⭐⭐⭐⭐ | 명확한 네이밍, 일관된 구조, 주석 충실 | -| **확장성** | ⭐⭐⭐⭐☆ | 모듈화 잘 됨, 설정 분리 완료 | -| **테스트 용이성** | ⭐⭐⭐☆☆ | 싱글톤 설정으로 모킹 어려움 | - -**종합 점수: 3.7 / 5.0** - ---- - -## 7. 요약 - -### 잘 된 점 - -1. **계층화된 예외 처리**: API 에러 코드별 명확한 예외 분류 -2. **비동기 컨텍스트 매니저**: 리소스 관리가 깔끔함 -3. **완전한 타입 힌트**: 모든 함수/메서드에 타입 명시 -4. **상세한 docstring**: 사용 예제까지 포함 -5. **재시도 로직**: Rate Limit 및 서버 에러 처리 -6. **Container 기반 게시**: Instagram API 표준 준수 - -### 개선 필요 항목 - -1. **Critical** - - `PermissionError` 이름 충돌 해결 - - 로그에서 토큰 마스킹 - - 설정 싱글톤 패턴 개선 - -2. **Major** - - 타임존 처리 추가 - - 캐러셀 병렬 처리 - - JSON 파싱 에러 처리 - - 시간 계산 정확도 개선 - -3. **Minor** - - 예제 코드 공통 모듈화 - - API 상수 분리 - - 모델 예제 일관성 - ---- - -## 8. 다음 단계 - -이 리뷰 결과를 바탕으로 **Stage 4 (설계 리팩토링)**에서: - -1. `PermissionError` → `InstagramPermissionError` 이름 변경 설계 -2. 로깅 시 민감 정보 마스킹 전략 수립 -3. 설정 팩토리 패턴 설계 -4. 타임존 처리 정책 정의 -5. 병렬 처리 아키텍처 설계 - ---- - -*이 리뷰는 `/review` 에이전트에 의해 자동 생성되었습니다.* diff --git a/poc/instagram1-difi/__init__.py b/poc/instagram1-difi/__init__.py deleted file mode 100644 index 31bbcc4..0000000 --- a/poc/instagram1-difi/__init__.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -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" diff --git a/poc/instagram1-difi/client.py b/poc/instagram1-difi/client.py deleted file mode 100644 index 29e17be..0000000 --- a/poc/instagram1-difi/client.py +++ /dev/null @@ -1,1045 +0,0 @@ -""" -Instagram Graph API 클라이언트 모듈 - -Instagram Graph API와의 통신을 담당하는 비동기 클라이언트입니다. -""" - -import asyncio -import logging -import time -from typing import Any, Optional - -import httpx - -from .config import InstagramSettings, settings -from .exceptions import ( - AuthenticationError, - ContainerStatusError, - ContainerTimeoutError, - InstagramAPIError, - MediaPublishError, - RateLimitError, - create_exception_from_error, -) -from .models import ( - Account, - Comment, - CommentList, - ErrorResponse, - InsightResponse, - Media, - MediaContainer, - MediaList, - TokenDebugResponse, - TokenInfo, -) - -# 로거 설정 -logger = logging.getLogger(__name__) - - -class InstagramGraphClient: - """ - Instagram Graph API 비동기 클라이언트 - - Instagram Graph API와의 모든 통신을 처리합니다. - 비동기 컨텍스트 매니저로 사용해야 합니다. - - Example: - ```python - async with InstagramGraphClient(access_token="...") as client: - account = await client.get_account() - print(account.username) - ``` - - Attributes: - access_token: Instagram 액세스 토큰 - app_id: Facebook 앱 ID (토큰 검증 시 필요) - app_secret: Facebook 앱 시크릿 (토큰 교환 시 필요) - settings: Instagram API 설정 - """ - - def __init__( - self, - access_token: Optional[str] = None, - app_id: Optional[str] = None, - app_secret: Optional[str] = None, - custom_settings: Optional[InstagramSettings] = None, - ): - """ - 클라이언트 초기화 - - Args: - access_token: Instagram 액세스 토큰 (없으면 설정에서 로드) - app_id: Facebook 앱 ID (없으면 설정에서 로드) - app_secret: Facebook 앱 시크릿 (없으면 설정에서 로드) - custom_settings: 커스텀 설정 (테스트용) - """ - self.settings = custom_settings or settings - self.access_token = access_token or self.settings.access_token - self.app_id = app_id or self.settings.app_id - self.app_secret = app_secret or self.settings.app_secret - self._client: Optional[httpx.AsyncClient] = None - self._account_id: Optional[str] = None - - if not self.access_token: - raise ValueError( - "access_token이 필요합니다. " - "파라미터로 전달하거나 INSTAGRAM_ACCESS_TOKEN 환경변수를 설정하세요." - ) - - async def __aenter__(self) -> "InstagramGraphClient": - """비동기 컨텍스트 매니저 진입""" - self._client = httpx.AsyncClient( - timeout=httpx.Timeout(self.settings.timeout), - follow_redirects=True, - ) - logger.debug("[InstagramGraphClient] 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("[InstagramGraphClient] HTTP 클라이언트 종료") - - # ========================================================================== - # 내부 메서드 - # ========================================================================== - - def _get_client(self) -> httpx.AsyncClient: - """HTTP 클라이언트 반환""" - if self._client is None: - raise RuntimeError( - "InstagramGraphClient는 비동기 컨텍스트 매니저로 사용해야 합니다. " - "예: async with InstagramGraphClient(...) as client:" - ) - return self._client - - def _mask_sensitive_params(self, params: dict[str, Any]) -> dict[str, Any]: - """ - 로깅용 파라미터에서 민감 정보 마스킹 - - Args: - params: 원본 파라미터 - - Returns: - 마스킹된 파라미터 복사본 - """ - SENSITIVE_KEYS = {"access_token", "client_secret", "input_token"} - masked = params.copy() - - for key in SENSITIVE_KEYS: - if key in masked and masked[key]: - value = str(masked[key]) - if len(value) > 14: - masked[key] = f"{value[:10]}...{value[-4:]}" - else: - masked[key] = "***" - - return masked - - async def _request( - self, - method: str, - url: str, - params: Optional[dict[str, Any]] = None, - data: Optional[dict[str, Any]] = None, - add_access_token: bool = True, - ) -> dict[str, Any]: - """ - 공통 HTTP 요청 처리 - - - Rate Limit 시 지수 백오프 재시도 - - 에러 응답 → 커스텀 예외 변환 - - 요청/응답 로깅 - - Args: - method: HTTP 메서드 (GET, POST 등) - url: 요청 URL - params: 쿼리 파라미터 - data: POST 데이터 - add_access_token: 액세스 토큰 자동 추가 여부 - - Returns: - API 응답 JSON - - Raises: - InstagramAPIError: API 에러 발생 시 - """ - client = self._get_client() - params = params or {} - - # 액세스 토큰 추가 - if add_access_token and "access_token" not in params: - params["access_token"] = self.access_token - - # 재시도 로직 - last_exception: Optional[Exception] = None - for attempt in range(self.settings.max_retries + 1): - try: - logger.debug( - f"[1/3] API 요청 시작: {method} {url} " - f"(attempt {attempt + 1}/{self.settings.max_retries + 1})" - ) - - response = await client.request( - method=method, - url=url, - params=params, - data=data, - ) - - logger.debug( - f"[2/3] API 응답 수신: status={response.status_code}" - ) - - # Rate Limit 체크 - if response.status_code == 429: - retry_after = int(response.headers.get("Retry-After", 60)) - if attempt < self.settings.max_retries: - wait_time = min( - self.settings.retry_base_delay * (2**attempt), - self.settings.retry_max_delay, - ) - wait_time = max(wait_time, retry_after) - logger.warning( - f"Rate limit 초과. {wait_time}초 후 재시도..." - ) - await asyncio.sleep(wait_time) - continue - raise RateLimitError( - message="Rate limit 초과", - retry_after=retry_after, - ) - - # 서버 에러 재시도 - if response.status_code >= 500: - if attempt < self.settings.max_retries: - wait_time = self.settings.retry_base_delay * (2**attempt) - logger.warning( - f"서버 에러 {response.status_code}. {wait_time}초 후 재시도..." - ) - await asyncio.sleep(wait_time) - continue - - # JSON 파싱 (안전 처리) - try: - response_data = response.json() - except ValueError as e: - logger.error( - f"[_request] JSON 파싱 실패: status={response.status_code}, " - f"body={response.text[:200]}" - ) - raise InstagramAPIError( - f"API 응답 파싱 실패: {e}", - code=response.status_code, - ) from e - - # API 에러 체크 - if "error" in response_data: - error_response = ErrorResponse.model_validate(response_data) - error = error_response.error - logger.error( - f"[3/3] API 에러: code={error.code}, message={error.message}" - ) - raise create_exception_from_error( - message=error.message, - code=error.code, - subcode=error.error_subcode, - fbtrace_id=error.fbtrace_id, - ) - - logger.debug(f"[3/3] API 요청 완료") - return response_data - - except httpx.HTTPError as e: - last_exception = e - if attempt < self.settings.max_retries: - wait_time = self.settings.retry_base_delay * (2**attempt) - logger.warning( - f"HTTP 에러: {e}. {wait_time}초 후 재시도..." - ) - await asyncio.sleep(wait_time) - continue - raise InstagramAPIError(f"HTTP 요청 실패: {e}") from e - - # 모든 재시도 실패 - raise InstagramAPIError( - f"최대 재시도 횟수 초과: {last_exception}" - ) - - async def _wait_for_container( - self, - container_id: str, - timeout: Optional[float] = None, - poll_interval: Optional[float] = None, - ) -> MediaContainer: - """ - 컨테이너 상태가 FINISHED가 될 때까지 대기 - - Args: - container_id: 컨테이너 ID - timeout: 타임아웃 (초) - poll_interval: 폴링 간격 (초) - - Returns: - 완료된 MediaContainer - - Raises: - ContainerStatusError: 컨테이너가 ERROR 상태가 된 경우 - ContainerTimeoutError: 타임아웃 초과 - """ - timeout = timeout or self.settings.container_timeout - poll_interval = poll_interval or self.settings.container_poll_interval - start_time = time.monotonic() # 정확한 시간 측정 - - logger.debug( - f"[컨테이너 대기] container_id={container_id}, " - f"timeout={timeout}s, poll_interval={poll_interval}s" - ) - - while True: - elapsed = time.monotonic() - start_time - if elapsed >= timeout: - break - - url = self.settings.get_instagram_url(container_id) - response = await self._request( - method="GET", - url=url, - params={"fields": "status_code,status"}, - ) - - container = MediaContainer.model_validate(response) - logger.debug( - f"[컨테이너 상태] status_code={container.status_code}, " - f"elapsed={elapsed:.1f}s" - ) - - if container.is_finished: - logger.info(f"[컨테이너 완료] container_id={container_id}") - return container - - if container.is_error: - raise ContainerStatusError( - f"컨테이너 처리 실패: {container.status}" - ) - - await asyncio.sleep(poll_interval) - - raise ContainerTimeoutError( - f"컨테이너 처리 타임아웃 ({timeout}초 초과): {container_id}" - ) - - # ========================================================================== - # 인증 API - # ========================================================================== - - async def debug_token(self) -> TokenDebugResponse: - """ - 현재 토큰 정보 조회 (유효성 검증) - - 토큰의 유효성, 만료 시간, 권한 등을 확인합니다. - - Returns: - TokenDebugResponse: 토큰 디버그 정보 - - Raises: - AuthenticationError: 토큰이 유효하지 않은 경우 - ValueError: app_id 또는 app_secret이 설정되지 않은 경우 - - Example: - ```python - async with InstagramGraphClient(...) as client: - token_info = await client.debug_token() - if token_info.data.is_valid: - print(f"토큰 유효, 만료: {token_info.data.expires_at_datetime}") - ``` - """ - if not self.app_id or not self.app_secret: - raise ValueError( - "토큰 검증에는 app_id와 app_secret이 필요합니다." - ) - - logger.info("[debug_token] 토큰 검증 시작") - url = self.settings.get_facebook_url("debug_token") - response = await self._request( - method="GET", - url=url, - params={ - "input_token": self.access_token, - "access_token": self.settings.app_access_token, - }, - add_access_token=False, - ) - - result = TokenDebugResponse.model_validate(response) - logger.info( - f"[debug_token] 완료: is_valid={result.data.is_valid}, " - f"expires_at={result.data.expires_at_datetime}" - ) - return result - - async def exchange_long_lived_token(self) -> TokenInfo: - """ - 단기 토큰을 장기 토큰(60일)으로 교환 - - Returns: - TokenInfo: 새로운 장기 토큰 정보 - - Raises: - AuthenticationError: 토큰 교환 실패 - ValueError: app_secret이 설정되지 않은 경우 - - Example: - ```python - async with InstagramGraphClient(access_token="SHORT_LIVED_TOKEN") as client: - new_token = await client.exchange_long_lived_token() - print(f"새 토큰: {new_token.access_token}") - print(f"만료: {new_token.expires_in}초 후") - ``` - """ - if not self.app_secret: - raise ValueError("토큰 교환에는 app_secret이 필요합니다.") - - logger.info("[exchange_long_lived_token] 토큰 교환 시작") - url = self.settings.get_instagram_url("access_token") - response = await self._request( - method="GET", - url=url, - params={ - "grant_type": "ig_exchange_token", - "client_secret": self.app_secret, - "access_token": self.access_token, - }, - add_access_token=False, - ) - - result = TokenInfo.model_validate(response) - logger.info( - f"[exchange_long_lived_token] 완료: expires_in={result.expires_in}초" - ) - return result - - async def refresh_token(self) -> TokenInfo: - """ - 장기 토큰 갱신 - - 만료 24시간 전부터 갱신 가능합니다. - - Returns: - TokenInfo: 갱신된 토큰 정보 - - Raises: - AuthenticationError: 토큰 갱신 실패 - - Example: - ```python - async with InstagramGraphClient(access_token="LONG_LIVED_TOKEN") as client: - refreshed = await client.refresh_token() - print(f"갱신된 토큰: {refreshed.access_token}") - ``` - """ - logger.info("[refresh_token] 토큰 갱신 시작") - url = self.settings.get_instagram_url("refresh_access_token") - response = await self._request( - method="GET", - url=url, - params={ - "grant_type": "ig_refresh_token", - "access_token": self.access_token, - }, - add_access_token=False, - ) - - result = TokenInfo.model_validate(response) - logger.info(f"[refresh_token] 완료: expires_in={result.expires_in}초") - return result - - # ========================================================================== - # 계정 API - # ========================================================================== - - async def get_account(self) -> Account: - """ - 현재 계정 정보 조회 - - Returns: - Account: 계정 정보 - - Example: - ```python - async with InstagramGraphClient(...) as client: - account = await client.get_account() - print(f"@{account.username}: {account.followers_count} followers") - ``` - """ - logger.info("[get_account] 계정 정보 조회 시작") - url = self.settings.get_instagram_url("me") - response = await self._request( - method="GET", - url=url, - params={ - "fields": ( - "id,username,name,account_type,profile_picture_url," - "followers_count,follows_count,media_count,biography,website" - ), - }, - ) - - result = Account.model_validate(response) - self._account_id = result.id - logger.info( - f"[get_account] 완료: @{result.username}, " - f"followers={result.followers_count}" - ) - return result - - async def get_account_id(self) -> str: - """ - 현재 계정 ID만 조회 - - Returns: - str: 계정 ID - - Note: - 캐시된 ID가 있으면 API 호출 없이 반환합니다. - """ - if self._account_id: - return self._account_id - - logger.info("[get_account_id] 계정 ID 조회 시작") - url = self.settings.get_instagram_url("me") - response = await self._request( - method="GET", - url=url, - params={"fields": "id"}, - ) - - self._account_id = response["id"] - logger.info(f"[get_account_id] 완료: {self._account_id}") - return self._account_id - - # ========================================================================== - # 미디어 API - # ========================================================================== - - async def get_media_list( - self, - limit: int = 25, - after: Optional[str] = None, - ) -> MediaList: - """ - 미디어 목록 조회 (페이지네이션 지원) - - Args: - limit: 조회할 미디어 수 (최대 100) - after: 페이지네이션 커서 - - Returns: - MediaList: 미디어 목록 - - Example: - ```python - async with InstagramGraphClient(...) as client: - media_list = await client.get_media_list(limit=10) - for media in media_list.data: - print(f"{media.media_type}: {media.caption[:50]}") - ``` - """ - logger.info(f"[get_media_list] 미디어 목록 조회: limit={limit}") - account_id = await self.get_account_id() - url = self.settings.get_instagram_url(f"{account_id}/media") - - 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", url=url, 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: 미디어 상세 정보 - - Example: - ```python - async with InstagramGraphClient(...) as client: - media = await client.get_media("17880000000000000") - print(f"좋아요: {media.like_count}, 댓글: {media.comments_count}") - ``` - """ - logger.info(f"[get_media] 미디어 상세 조회: media_id={media_id}") - url = self.settings.get_instagram_url(media_id) - response = await self._request( - method="GET", - url=url, - 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}, " - f"likes={result.like_count}" - ) - return result - - async def publish_image( - self, - image_url: str, - caption: Optional[str] = None, - ) -> Media: - """ - 이미지 게시 - - Container 생성 → 상태 확인 → 게시의 3단계 프로세스를 수행합니다. - - Args: - image_url: 공개 접근 가능한 이미지 URL - caption: 게시물 캡션 - - Returns: - Media: 게시된 미디어 정보 - - Raises: - MediaPublishError: 게시 실패 - ContainerTimeoutError: 컨테이너 처리 타임아웃 - - Example: - ```python - async with InstagramGraphClient(...) as client: - media = await client.publish_image( - image_url="https://example.com/image.jpg", - caption="My awesome photo! #photography" - ) - print(f"게시 완료: {media.permalink}") - ``` - """ - logger.info(f"[publish_image] 이미지 게시 시작: {image_url[:50]}...") - account_id = await self.get_account_id() - - # Step 1: Container 생성 - logger.debug("[publish_image] Step 1: Container 생성") - container_url = self.settings.get_instagram_url(f"{account_id}/media") - container_params: dict[str, Any] = {"image_url": image_url} - if caption: - container_params["caption"] = caption - - container_response = await self._request( - method="POST", - url=container_url, - params=container_params, - ) - container_id = container_response["id"] - logger.debug(f"[publish_image] Container 생성 완료: {container_id}") - - # Step 2: Container 상태 대기 - logger.debug("[publish_image] Step 2: Container 상태 대기") - await self._wait_for_container(container_id) - - # Step 3: 게시 - logger.debug("[publish_image] Step 3: 게시") - publish_url = self.settings.get_instagram_url(f"{account_id}/media_publish") - publish_response = await self._request( - method="POST", - url=publish_url, - 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 - caption: 게시물 캡션 - share_to_feed: 피드에 공유 여부 - - Returns: - Media: 게시된 미디어 정보 - - Raises: - MediaPublishError: 게시 실패 - ContainerTimeoutError: 컨테이너 처리 타임아웃 - - Example: - ```python - async with InstagramGraphClient(...) as client: - media = await client.publish_video( - video_url="https://example.com/video.mp4", - caption="Check out this video! #video" - ) - print(f"게시 완료: {media.permalink}") - ``` - """ - logger.info(f"[publish_video] 비디오 게시 시작: {video_url[:50]}...") - account_id = await self.get_account_id() - - # Step 1: Container 생성 - logger.debug("[publish_video] Step 1: Container 생성") - container_url = self.settings.get_instagram_url(f"{account_id}/media") - 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", - url=container_url, - params=container_params, - ) - container_id = container_response["id"] - logger.debug(f"[publish_video] Container 생성 완료: {container_id}") - - # Step 2: Container 상태 대기 (비디오는 더 오래 걸릴 수 있음) - logger.debug("[publish_video] Step 2: Container 상태 대기") - await self._wait_for_container( - container_id, - timeout=self.settings.container_timeout * 2, # 비디오는 2배 시간 - ) - - # Step 3: 게시 - logger.debug("[publish_video] Step 3: 게시") - publish_url = self.settings.get_instagram_url(f"{account_id}/media_publish") - publish_response = await self._request( - method="POST", - url=publish_url, - 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개가 아닌 경우 - MediaPublishError: 게시 실패 - - Example: - ```python - async with InstagramGraphClient(...) as client: - media = await client.publish_carousel( - media_urls=[ - "https://example.com/image1.jpg", - "https://example.com/image2.jpg", - ], - caption="My carousel post!" - ) - ``` - """ - 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 병렬 생성 - logger.debug( - f"[publish_carousel] Step 1: {len(media_urls)}개 Container 병렬 생성" - ) - - async def create_item_container(url: str, index: int) -> str: - """개별 이미지 컨테이너 생성""" - container_url = self.settings.get_instagram_url(f"{account_id}/media") - response = await self._request( - method="POST", - url=container_url, - params={ - "image_url": url, - "is_carousel_item": "true", - }, - ) - 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 생성 - logger.debug("[publish_carousel] Step 2: 캐러셀 Container 생성") - carousel_url = self.settings.get_instagram_url(f"{account_id}/media") - 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", - url=carousel_url, - params=carousel_params, - ) - carousel_id = carousel_response["id"] - - # Step 3: Container 상태 대기 - logger.debug("[publish_carousel] Step 3: Container 상태 대기") - await self._wait_for_container(carousel_id) - - # Step 4: 게시 - logger.debug("[publish_carousel] Step 4: 게시") - publish_url = self.settings.get_instagram_url(f"{account_id}/media_publish") - publish_response = await self._request( - method="POST", - url=publish_url, - 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 - - # ========================================================================== - # 인사이트 API - # ========================================================================== - - async def get_account_insights( - self, - metrics: list[str], - period: str = "day", - ) -> InsightResponse: - """ - 계정 인사이트 조회 - - Args: - metrics: 조회할 메트릭 목록 - - impressions: 노출 수 - - reach: 도달 수 - - profile_views: 프로필 조회 수 - - accounts_engaged: 참여 계정 수 - - total_interactions: 총 상호작용 수 - period: 기간 (day, week, days_28) - - Returns: - InsightResponse: 인사이트 데이터 - - Example: - ```python - async with InstagramGraphClient(...) as client: - insights = await client.get_account_insights( - metrics=["impressions", "reach"], - period="day" - ) - for insight in insights.data: - print(f"{insight.name}: {insight.latest_value}") - ``` - """ - logger.info( - f"[get_account_insights] 계정 인사이트 조회: " - f"metrics={metrics}, period={period}" - ) - account_id = await self.get_account_id() - url = self.settings.get_instagram_url(f"{account_id}/insights") - - response = await self._request( - method="GET", - url=url, - params={ - "metric": ",".join(metrics), - "period": period, - "metric_type": "total_value", - }, - ) - - result = InsightResponse.model_validate(response) - logger.info(f"[get_account_insights] 완료: {len(result.data)}개 메트릭") - return result - - async def get_media_insights( - self, - media_id: str, - metrics: Optional[list[str]] = None, - ) -> InsightResponse: - """ - 미디어 인사이트 조회 - - Args: - media_id: 미디어 ID - metrics: 조회할 메트릭 (기본: impressions, reach, engagement, saved) - - Returns: - InsightResponse: 인사이트 데이터 - - Example: - ```python - async with InstagramGraphClient(...) as client: - insights = await client.get_media_insights("17880000000000000") - reach = insights.get_metric("reach") - print(f"도달: {reach.latest_value}") - ``` - """ - if metrics is None: - metrics = ["impressions", "reach", "engagement", "saved"] - - logger.info( - f"[get_media_insights] 미디어 인사이트 조회: " - f"media_id={media_id}, metrics={metrics}" - ) - url = self.settings.get_instagram_url(f"{media_id}/insights") - - response = await self._request( - method="GET", - url=url, - params={"metric": ",".join(metrics)}, - ) - - result = InsightResponse.model_validate(response) - logger.info(f"[get_media_insights] 완료: {len(result.data)}개 메트릭") - return result - - # ========================================================================== - # 댓글 API - # ========================================================================== - - async def get_comments( - self, - media_id: str, - limit: int = 50, - ) -> CommentList: - """ - 미디어의 댓글 목록 조회 - - Args: - media_id: 미디어 ID - limit: 조회할 댓글 수 (최대 50) - - Returns: - CommentList: 댓글 목록 - - Example: - ```python - async with InstagramGraphClient(...) as client: - comments = await client.get_comments("17880000000000000") - for comment in comments.data: - print(f"@{comment.username}: {comment.text}") - ``` - """ - logger.info( - f"[get_comments] 댓글 조회: media_id={media_id}, limit={limit}" - ) - url = self.settings.get_instagram_url(f"{media_id}/comments") - - response = await self._request( - method="GET", - url=url, - params={ - "fields": ( - "id,text,username,timestamp,like_count," - "replies{id,text,username,timestamp,like_count}" - ), - "limit": min(limit, 50), - }, - ) - - result = CommentList.model_validate(response) - logger.info(f"[get_comments] 완료: {len(result.data)}개 댓글") - return result - - async def reply_comment( - self, - comment_id: str, - message: str, - ) -> Comment: - """ - 댓글에 답글 작성 - - Args: - comment_id: 댓글 ID - message: 답글 내용 - - Returns: - Comment: 작성된 답글 정보 - - Example: - ```python - async with InstagramGraphClient(...) as client: - reply = await client.reply_comment( - comment_id="17890000000000000", - message="Thanks for your comment!" - ) - print(f"답글 작성 완료: {reply.id}") - ``` - """ - logger.info( - f"[reply_comment] 답글 작성: comment_id={comment_id}" - ) - url = self.settings.get_instagram_url(f"{comment_id}/replies") - - response = await self._request( - method="POST", - url=url, - params={"message": message}, - ) - - # 답글 ID만 반환되므로, 추가 정보 조회 - reply_id = response["id"] - reply_url = self.settings.get_instagram_url(reply_id) - reply_response = await self._request( - method="GET", - url=reply_url, - params={"fields": "id,text,username,timestamp,like_count"}, - ) - - result = Comment.model_validate(reply_response) - logger.info(f"[reply_comment] 완료: reply_id={result.id}") - return result diff --git a/poc/instagram1-difi/config.py b/poc/instagram1-difi/config.py deleted file mode 100644 index 91d4e8d..0000000 --- a/poc/instagram1-difi/config.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -Instagram Graph API 설정 모듈 - -환경변수를 통해 Instagram API 연동에 필요한 설정을 관리합니다. -""" - -from functools import lru_cache -from typing import Optional - -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class InstagramSettings(BaseSettings): - """ - Instagram Graph API 설정 - - 환경변수 또는 .env 파일에서 설정을 로드합니다. - - Attributes: - app_id: Facebook/Instagram 앱 ID - app_secret: Facebook/Instagram 앱 시크릿 - access_token: Instagram 액세스 토큰 - api_version: Graph API 버전 (기본: v21.0) - base_url: Instagram Graph API 기본 URL - facebook_base_url: Facebook Graph API 기본 URL (토큰 디버그용) - timeout: HTTP 요청 타임아웃 (초) - max_retries: 최대 재시도 횟수 - retry_base_delay: 재시도 기본 대기 시간 (초) - retry_max_delay: 재시도 최대 대기 시간 (초) - """ - - model_config = SettingsConfigDict( - env_prefix="INSTAGRAM_", - env_file=".env", - env_file_encoding="utf-8", - extra="ignore", - ) - - # ========================================================================== - # 필수 설정 (환경변수에서 로드) - # ========================================================================== - app_id: Optional[str] = Field( - default=None, - description="Facebook/Instagram 앱 ID", - ) - app_secret: Optional[str] = Field( - default=None, - description="Facebook/Instagram 앱 시크릿", - ) - access_token: Optional[str] = Field( - default=None, - description="Instagram 액세스 토큰", - ) - - # ========================================================================== - # API 설정 - # ========================================================================== - api_version: str = Field( - default="v21.0", - description="Graph API 버전", - ) - base_url: str = Field( - default="https://graph.instagram.com", - description="Instagram Graph API 기본 URL", - ) - facebook_base_url: str = Field( - default="https://graph.facebook.com", - description="Facebook Graph API 기본 URL (토큰 디버그용)", - ) - - # ========================================================================== - # HTTP 클라이언트 설정 - # ========================================================================== - timeout: float = Field( - default=30.0, - description="HTTP 요청 타임아웃 (초)", - ) - max_retries: int = Field( - default=3, - description="최대 재시도 횟수", - ) - retry_base_delay: float = Field( - default=1.0, - description="재시도 기본 대기 시간 (초)", - ) - retry_max_delay: float = Field( - default=60.0, - description="재시도 최대 대기 시간 (초)", - ) - - # ========================================================================== - # 미디어 게시 설정 - # ========================================================================== - container_poll_interval: float = Field( - default=2.0, - description="컨테이너 상태 확인 간격 (초)", - ) - container_timeout: float = Field( - default=60.0, - description="컨테이너 상태 확인 타임아웃 (초)", - ) - - @property - def app_access_token(self) -> str: - """앱 액세스 토큰 (토큰 디버그용)""" - if not self.app_id or not self.app_secret: - raise ValueError("app_id와 app_secret이 설정되어야 합니다.") - return f"{self.app_id}|{self.app_secret}" - - def get_instagram_url(self, endpoint: str) -> str: - """Instagram Graph API 전체 URL 생성""" - endpoint = endpoint.lstrip("/") - return f"{self.base_url}/{endpoint}" - - def get_facebook_url(self, endpoint: str) -> str: - """Facebook Graph API 전체 URL 생성 (버전 포함)""" - endpoint = endpoint.lstrip("/") - return f"{self.facebook_base_url}/{self.api_version}/{endpoint}" - - -@lru_cache() -def get_settings() -> InstagramSettings: - """ - 설정 인스턴스 반환 (캐싱됨) - - 테스트 시 캐시 초기화: - get_settings.cache_clear() - - Returns: - InstagramSettings 인스턴스 - """ - return InstagramSettings() - - -# 하위 호환성을 위한 기본 인스턴스 -# @deprecated: get_settings() 사용 권장 -settings = get_settings() diff --git a/poc/instagram1-difi/examples/__init__.py b/poc/instagram1-difi/examples/__init__.py deleted file mode 100644 index 0f16ea0..0000000 --- a/poc/instagram1-difi/examples/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Instagram Graph API 예제 모듈 - -각 기능별 실행 가능한 예제를 제공합니다. -""" diff --git a/poc/instagram1-difi/examples/account_example.py b/poc/instagram1-difi/examples/account_example.py deleted file mode 100644 index 19bc1da..0000000 --- a/poc/instagram1-difi/examples/account_example.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Instagram Graph API 계정 정보 조회 예제 - -비즈니스/크리에이터 계정의 프로필 정보를 조회합니다. - -실행 방법: - ```bash - export INSTAGRAM_ACCESS_TOKEN="your_access_token" - python -m poc.instagram1.examples.account_example - ``` -""" - -import asyncio -import logging -import sys - -# 로깅 설정 -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], -) -logger = logging.getLogger(__name__) - - -async def example_get_account(): - """계정 정보 조회 예제""" - from poc.instagram1 import InstagramGraphClient, AuthenticationError - - print("\n" + "=" * 60) - print("계정 정보 조회") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - account = await client.get_account() - - print(f"\n📱 계정 정보") - print("-" * 40) - print(f"🆔 ID: {account.id}") - print(f"👤 사용자명: @{account.username}") - print(f"📛 이름: {account.name or '(없음)'}") - print(f"📊 계정 타입: {account.account_type}") - print(f"🖼️ 프로필 사진: {account.profile_picture_url or '(없음)'}") - - print(f"\n📈 통계") - print("-" * 40) - print(f"👥 팔로워: {account.followers_count:,}명") - print(f"👤 팔로잉: {account.follows_count:,}명") - print(f"📷 게시물: {account.media_count:,}개") - - if account.biography: - print(f"\n📝 자기소개") - print("-" * 40) - print(f"{account.biography}") - - if account.website: - print(f"\n🌐 웹사이트: {account.website}") - - except AuthenticationError as e: - print(f"❌ 인증 에러: {e}") - except Exception as e: - print(f"❌ 에러: {e}") - - -async def example_get_account_id(): - """계정 ID만 조회 예제""" - from poc.instagram1 import InstagramGraphClient - - print("\n" + "=" * 60) - print("계정 ID 조회 (캐시 테스트)") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - # 첫 번째 호출 (API 요청) - print("\n1️⃣ 첫 번째 조회 (API 호출)") - account_id = await client.get_account_id() - print(f" 계정 ID: {account_id}") - - # 두 번째 호출 (캐시 사용) - print("\n2️⃣ 두 번째 조회 (캐시)") - account_id_cached = await client.get_account_id() - print(f" 계정 ID: {account_id_cached}") - - print("\n✅ 캐시 동작 확인 완료") - - except Exception as e: - print(f"❌ 에러: {e}") - - -async def main(): - """모든 계정 예제 실행""" - print("\n👤 Instagram Graph API 계정 예제") - print("=" * 60) - - # 계정 정보 조회 - await example_get_account() - - # 계정 ID 캐시 테스트 - await example_get_account_id() - - print("\n" + "=" * 60) - print("✅ 예제 완료") - print("=" * 60) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/poc/instagram1-difi/examples/auth_example.py b/poc/instagram1-difi/examples/auth_example.py deleted file mode 100644 index 5d9b1d6..0000000 --- a/poc/instagram1-difi/examples/auth_example.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -Instagram Graph API 인증 예제 - -토큰 검증, 교환, 갱신 기능을 테스트합니다. - -실행 방법: - ```bash - # 환경변수 설정 - export INSTAGRAM_ACCESS_TOKEN="your_access_token" - export INSTAGRAM_APP_ID="your_app_id" - export INSTAGRAM_APP_SECRET="your_app_secret" - - # 실행 - cd /path/to/project - python -m poc.instagram1.examples.auth_example - ``` -""" - -import asyncio -import logging -import sys -from datetime import datetime, timezone - -# 로깅 설정 -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], -) -logger = logging.getLogger(__name__) - - -async def example_debug_token(): - """토큰 검증 예제""" - from poc.instagram1 import InstagramGraphClient, AuthenticationError - - print("\n" + "=" * 60) - print("1. 토큰 검증 (Debug Token)") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - token_info = await client.debug_token() - data = token_info.data - - print(f"✅ 토큰 유효: {data.is_valid}") - print(f"📱 앱 이름: {data.application}") - print(f"👤 사용자 ID: {data.user_id}") - print(f"🔐 권한: {', '.join(data.scopes)}") - print(f"⏰ 만료 시각: {data.expires_at_datetime}") - - # 만료까지 남은 시간 계산 - remaining = data.expires_at_datetime - datetime.now(timezone.utc) - print(f"⏳ 남은 시간: {remaining.days}일 {remaining.seconds // 3600}시간") - - if data.is_expired: - print("⚠️ 토큰이 만료되었습니다. 갱신이 필요합니다.") - - except AuthenticationError as e: - print(f"❌ 인증 에러: {e}") - except ValueError as e: - print(f"⚠️ 설정 에러: {e}") - print(" app_id와 app_secret 환경변수를 설정해주세요.") - except Exception as e: - print(f"❌ 에러: {e}") - - -async def example_exchange_token(): - """토큰 교환 예제 (단기 → 장기)""" - from poc.instagram1 import InstagramGraphClient, AuthenticationError - - print("\n" + "=" * 60) - print("2. 토큰 교환 (Short-lived → Long-lived)") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - new_token = await client.exchange_long_lived_token() - - print(f"✅ 토큰 교환 성공") - print(f"🔑 새 토큰: {new_token.access_token[:20]}...") - print(f"📝 토큰 타입: {new_token.token_type}") - print(f"⏰ 유효 기간: {new_token.expires_in}초 ({new_token.expires_in // 86400}일)") - - print("\n💡 새 토큰을 환경변수에 저장하세요:") - print(f' export INSTAGRAM_ACCESS_TOKEN="{new_token.access_token}"') - - except AuthenticationError as e: - print(f"❌ 인증 에러: {e}") - print(" 이미 장기 토큰이거나, 토큰이 유효하지 않습니다.") - except ValueError as e: - print(f"⚠️ 설정 에러: {e}") - except Exception as e: - print(f"❌ 에러: {e}") - - -async def example_refresh_token(): - """토큰 갱신 예제""" - from poc.instagram1 import InstagramGraphClient, AuthenticationError - - print("\n" + "=" * 60) - print("3. 토큰 갱신 (Long-lived Token Refresh)") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - refreshed = await client.refresh_token() - - print(f"✅ 토큰 갱신 성공") - print(f"🔑 갱신된 토큰: {refreshed.access_token[:20]}...") - print(f"⏰ 유효 기간: {refreshed.expires_in}초 ({refreshed.expires_in // 86400}일)") - - except AuthenticationError as e: - print(f"❌ 인증 에러: {e}") - print(" 장기 토큰만 갱신 가능합니다.") - print(" 만료 24시간 전부터 갱신할 수 있습니다.") - except Exception as e: - print(f"❌ 에러: {e}") - - -async def main(): - """모든 인증 예제 실행""" - print("\n🔐 Instagram Graph API 인증 예제") - print("=" * 60) - - # 1. 토큰 검증 - await example_debug_token() - - # 2. 토큰 교환 (주의: 실제로 토큰이 교환됩니다) - # await example_exchange_token() - - # 3. 토큰 갱신 (주의: 실제로 토큰이 갱신됩니다) - # await example_refresh_token() - - print("\n" + "=" * 60) - print("✅ 예제 완료") - print("=" * 60) - print("\n💡 토큰 교환/갱신을 테스트하려면 해당 함수의 주석을 해제하세요.") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/poc/instagram1-difi/examples/comments_example.py b/poc/instagram1-difi/examples/comments_example.py deleted file mode 100644 index 7b3f6df..0000000 --- a/poc/instagram1-difi/examples/comments_example.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -Instagram Graph API 댓글 관리 예제 - -미디어의 댓글을 조회하고 답글을 작성합니다. - -실행 방법: - ```bash - export INSTAGRAM_ACCESS_TOKEN="your_access_token" - python -m poc.instagram1.examples.comments_example - ``` -""" - -import asyncio -import logging -import sys - -# 로깅 설정 -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], -) -logger = logging.getLogger(__name__) - - -async def example_get_comments(): - """댓글 목록 조회 예제""" - from poc.instagram1 import InstagramGraphClient - - print("\n" + "=" * 60) - print("1. 댓글 목록 조회") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - # 미디어 목록에서 댓글이 있는 게시물 찾기 - media_list = await client.get_media_list(limit=10) - - media_with_comments = None - for media in media_list.data: - if media.comments_count > 0: - media_with_comments = media - break - - if not media_with_comments: - print("⚠️ 댓글이 있는 게시물이 없습니다.") - return - - print(f"\n📷 미디어 정보") - print("-" * 50) - print(f" ID: {media_with_comments.id}") - caption_preview = ( - media_with_comments.caption[:40] + "..." - if media_with_comments.caption and len(media_with_comments.caption) > 40 - else media_with_comments.caption or "(캡션 없음)" - ) - print(f" 캡션: {caption_preview}") - print(f" 댓글 수: {media_with_comments.comments_count}") - - # 댓글 조회 - comments = await client.get_comments( - media_id=media_with_comments.id, - limit=10, - ) - - print(f"\n💬 댓글 목록 ({len(comments.data)}개)") - print("-" * 50) - - for i, comment in enumerate(comments.data, 1): - print(f"\n{i}. @{comment.username}") - print(f" 📝 {comment.text}") - print(f" ❤️ 좋아요: {comment.like_count}") - print(f" 📅 {comment.timestamp}") - - # 답글이 있는 경우 - if comment.replies and comment.replies.data: - print(f" 💬 답글 ({len(comment.replies.data)}개):") - for reply in comment.replies.data[:3]: # 최대 3개만 표시 - print(f" └─ @{reply.username}: {reply.text[:30]}...") - - except Exception as e: - print(f"❌ 에러: {e}") - - -async def example_find_comments_to_reply(): - """답글이 필요한 댓글 찾기""" - from poc.instagram1 import InstagramGraphClient - - print("\n" + "=" * 60) - print("2. 답글이 필요한 댓글 찾기") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - # 최근 게시물 조회 - media_list = await client.get_media_list(limit=5) - - unanswered_comments = [] - - for media in media_list.data: - if media.comments_count == 0: - continue - - comments = await client.get_comments(media.id, limit=20) - - for comment in comments.data: - # 답글이 없는 댓글 찾기 - has_replies = comment.replies and len(comment.replies.data) > 0 - if not has_replies: - unanswered_comments.append({ - "media_id": media.id, - "comment": comment, - "media_caption": media.caption[:30] if media.caption else "(없음)", - }) - - if not unanswered_comments: - print("✅ 모든 댓글에 답글이 달려있습니다.") - return - - print(f"\n⚠️ 답글이 필요한 댓글 ({len(unanswered_comments)}개)") - print("-" * 50) - - for i, item in enumerate(unanswered_comments[:10], 1): # 최대 10개 - comment = item["comment"] - print(f"\n{i}. 게시물: {item['media_caption']}...") - print(f" 댓글 ID: {comment.id}") - print(f" @{comment.username}: {comment.text[:50]}...") - print(f" 📅 {comment.timestamp}") - - except Exception as e: - print(f"❌ 에러: {e}") - - -async def example_reply_comment(): - """댓글에 답글 작성 예제 (테스트용 - 실제 게시됨)""" - from poc.instagram1 import InstagramGraphClient - - print("\n" + "=" * 60) - print("3. 댓글 답글 작성 (테스트)") - print("=" * 60) - - # ⚠️ 실제 답글이 작성되므로 주의 - print(f"\n⚠️ 이 예제는 실제로 답글을 작성합니다!") - print(f" 테스트하려면 아래 코드의 주석을 해제하세요.") - - # try: - # async with InstagramGraphClient() as client: - # # 먼저 답글할 댓글 찾기 - # media_list = await client.get_media_list(limit=5) - # - # target_comment = None - # for media in media_list.data: - # if media.comments_count > 0: - # comments = await client.get_comments(media.id, limit=5) - # if comments.data: - # target_comment = comments.data[0] - # break - # - # if not target_comment: - # print("⚠️ 답글할 댓글을 찾을 수 없습니다.") - # return - # - # print(f"\n답글 대상 댓글:") - # print(f" ID: {target_comment.id}") - # print(f" @{target_comment.username}: {target_comment.text[:50]}...") - # - # # 답글 작성 - # reply = await client.reply_comment( - # comment_id=target_comment.id, - # message="Thanks for your comment! 🙏" - # ) - # - # print(f"\n✅ 답글 작성 완료!") - # print(f" 답글 ID: {reply.id}") - # print(f" 내용: {reply.text}") - # - # except Exception as e: - # print(f"❌ 에러: {e}") - - -async def example_comment_analytics(): - """댓글 분석 예제""" - from poc.instagram1 import InstagramGraphClient - - print("\n" + "=" * 60) - print("4. 댓글 분석") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - # 최근 게시물의 댓글 분석 - media_list = await client.get_media_list(limit=10) - - total_comments = 0 - total_likes_on_comments = 0 - commenters = set() - all_comments = [] - - for media in media_list.data: - if media.comments_count == 0: - continue - - comments = await client.get_comments(media.id, limit=50) - - for comment in comments.data: - total_comments += 1 - total_likes_on_comments += comment.like_count - if comment.username: - commenters.add(comment.username) - all_comments.append(comment) - - print(f"\n📊 댓글 통계 (최근 10개 게시물)") - print("-" * 50) - print(f" 💬 총 댓글 수: {total_comments:,}") - print(f" 👥 고유 댓글 작성자: {len(commenters):,}명") - print(f" ❤️ 댓글 좋아요 합계: {total_likes_on_comments:,}") - - if total_comments > 0: - avg_likes = total_likes_on_comments / total_comments - print(f" 📈 댓글당 평균 좋아요: {avg_likes:.1f}") - - # 가장 좋아요가 많은 댓글 - if all_comments: - top_comment = max(all_comments, key=lambda c: c.like_count) - print(f"\n🏆 가장 인기 있는 댓글") - print(f" @{top_comment.username}: {top_comment.text[:40]}...") - print(f" ❤️ {top_comment.like_count}개 좋아요") - - # 가장 많이 댓글 단 사용자 - if commenters: - from collections import Counter - commenter_counts = Counter( - c.username for c in all_comments if c.username - ) - top_commenter = commenter_counts.most_common(1)[0] - print(f"\n🥇 가장 활발한 댓글러") - print(f" @{top_commenter[0]}: {top_commenter[1]}개 댓글") - - except Exception as e: - print(f"❌ 에러: {e}") - - -async def main(): - """모든 댓글 예제 실행""" - print("\n💬 Instagram Graph API 댓글 예제") - print("=" * 60) - - # 댓글 목록 조회 - await example_get_comments() - - # 답글이 필요한 댓글 찾기 - await example_find_comments_to_reply() - - # 답글 작성 (기본적으로 비활성화) - await example_reply_comment() - - # 댓글 분석 - await example_comment_analytics() - - print("\n" + "=" * 60) - print("✅ 예제 완료") - print("=" * 60) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/poc/instagram1-difi/examples/insights_example.py b/poc/instagram1-difi/examples/insights_example.py deleted file mode 100644 index 2e6a8f0..0000000 --- a/poc/instagram1-difi/examples/insights_example.py +++ /dev/null @@ -1,239 +0,0 @@ -""" -Instagram Graph API 인사이트 조회 예제 - -계정 및 미디어의 성과 지표를 조회합니다. - -실행 방법: - ```bash - export INSTAGRAM_ACCESS_TOKEN="your_access_token" - python -m poc.instagram1.examples.insights_example - ``` -""" - -import asyncio -import logging -import sys - -# 로깅 설정 -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], -) -logger = logging.getLogger(__name__) - - -async def example_account_insights(): - """계정 인사이트 조회 예제""" - from poc.instagram1 import InstagramGraphClient, InstagramPermissionError - - print("\n" + "=" * 60) - print("1. 계정 인사이트 조회") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - # 일간 인사이트 조회 - metrics = ["impressions", "reach", "profile_views"] - insights = await client.get_account_insights( - metrics=metrics, - period="day", - ) - - print(f"\n📊 계정 인사이트 (일간)") - print("-" * 50) - - for insight in insights.data: - value = insight.latest_value - print(f"\n📈 {insight.title}") - print(f" 이름: {insight.name}") - print(f" 기간: {insight.period}") - print(f" 값: {value:,}" if isinstance(value, int) else f" 값: {value}") - if insight.description: - print(f" 설명: {insight.description[:50]}...") - - # 특정 메트릭 조회 - reach = insights.get_metric("reach") - if reach: - print(f"\n✅ 도달(reach) 직접 조회: {reach.latest_value:,}") - - except InstagramPermissionError as e: - print(f"❌ 권한 에러: {e}") - print(" 비즈니스 계정이 필요하거나, 인사이트 권한이 없습니다.") - except Exception as e: - print(f"❌ 에러: {e}") - - -async def example_account_insights_periods(): - """다양한 기간의 계정 인사이트 조회""" - from poc.instagram1 import InstagramGraphClient - - print("\n" + "=" * 60) - print("2. 기간별 계정 인사이트 비교") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - periods = ["day", "week", "days_28"] - metrics = ["impressions", "reach"] - - for period in periods: - print(f"\n📅 기간: {period}") - print("-" * 30) - - try: - insights = await client.get_account_insights( - metrics=metrics, - period=period, - ) - - for insight in insights.data: - value = insight.latest_value - print(f" {insight.name}: {value:,}" if isinstance(value, int) else f" {insight.name}: {value}") - - except Exception as e: - print(f" ⚠️ 조회 실패: {e}") - - except Exception as e: - print(f"❌ 에러: {e}") - - -async def example_media_insights(): - """미디어 인사이트 조회 예제""" - from poc.instagram1 import InstagramGraphClient, InstagramPermissionError - - print("\n" + "=" * 60) - print("3. 미디어 인사이트 조회") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - # 먼저 미디어 목록 조회 - media_list = await client.get_media_list(limit=3) - if not media_list.data: - print("⚠️ 게시물이 없습니다.") - return - - for media in media_list.data: - print(f"\n📷 미디어: {media.id}") - print(f" 타입: {media.media_type}") - caption_preview = ( - media.caption[:30] + "..." - if media.caption and len(media.caption) > 30 - else media.caption or "(캡션 없음)" - ) - print(f" 캡션: {caption_preview}") - print("-" * 40) - - try: - insights = await client.get_media_insights(media.id) - - for insight in insights.data: - value = insight.latest_value - print(f" 📈 {insight.name}: {value:,}" if isinstance(value, int) else f" 📈 {insight.name}: {value}") - - except InstagramPermissionError as e: - print(f" ⚠️ 권한 부족: 인사이트 조회 불가") - except Exception as e: - print(f" ⚠️ 조회 실패: {e}") - - except Exception as e: - print(f"❌ 에러: {e}") - - -async def example_media_insights_detail(): - """미디어 인사이트 상세 조회""" - from poc.instagram1 import InstagramGraphClient - - print("\n" + "=" * 60) - print("4. 미디어 인사이트 상세 분석") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - # 첫 번째 미디어 가져오기 - media_list = await client.get_media_list(limit=1) - if not media_list.data: - print("⚠️ 게시물이 없습니다.") - return - - media = media_list.data[0] - print(f"\n📷 분석 대상 미디어") - print(f" ID: {media.id}") - print(f" 게시일: {media.timestamp}") - print(f" 좋아요: {media.like_count:,}") - print(f" 댓글: {media.comments_count:,}") - - # 다양한 메트릭 조회 - all_metrics = [ - "impressions", # 노출 수 - "reach", # 도달 수 - "engagement", # 상호작용 수 (좋아요 + 댓글 + 저장) - "saved", # 저장 수 - ] - - try: - insights = await client.get_media_insights( - media_id=media.id, - metrics=all_metrics, - ) - - print(f"\n📊 성과 분석") - print("-" * 50) - - # 인사이트 값 추출 - impressions = insights.get_metric("impressions") - reach = insights.get_metric("reach") - engagement = insights.get_metric("engagement") - saved = insights.get_metric("saved") - - if impressions and reach: - imp_val = impressions.latest_value or 0 - reach_val = reach.latest_value or 0 - print(f" 👁️ 노출: {imp_val:,}") - print(f" 🎯 도달: {reach_val:,}") - if reach_val > 0: - frequency = imp_val / reach_val - print(f" 🔄 빈도: {frequency:.2f} (노출/도달)") - - if engagement: - eng_val = engagement.latest_value or 0 - print(f" 💪 참여: {eng_val:,}") - if reach and reach.latest_value: - eng_rate = (eng_val / reach.latest_value) * 100 - print(f" 📈 참여율: {eng_rate:.2f}%") - - if saved: - print(f" 💾 저장: {saved.latest_value:,}") - - except Exception as e: - print(f" ⚠️ 인사이트 조회 실패: {e}") - - except Exception as e: - print(f"❌ 에러: {e}") - - -async def main(): - """모든 인사이트 예제 실행""" - print("\n📊 Instagram Graph API 인사이트 예제") - print("=" * 60) - - # 계정 인사이트 - await example_account_insights() - - # 기간별 인사이트 비교 - await example_account_insights_periods() - - # 미디어 인사이트 - await example_media_insights() - - # 미디어 인사이트 상세 - await example_media_insights_detail() - - print("\n" + "=" * 60) - print("✅ 예제 완료") - print("=" * 60) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/poc/instagram1-difi/examples/media_example.py b/poc/instagram1-difi/examples/media_example.py deleted file mode 100644 index e88143d..0000000 --- a/poc/instagram1-difi/examples/media_example.py +++ /dev/null @@ -1,236 +0,0 @@ -""" -Instagram Graph API 미디어 관리 예제 - -미디어 조회, 이미지/비디오 게시 기능을 테스트합니다. - -실행 방법: - ```bash - export INSTAGRAM_ACCESS_TOKEN="your_access_token" - python -m poc.instagram1.examples.media_example - ``` -""" - -import asyncio -import logging -import sys - -# 로깅 설정 -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], -) -logger = logging.getLogger(__name__) - - -async def example_get_media_list(): - """미디어 목록 조회 예제""" - from poc.instagram1 import InstagramGraphClient - - print("\n" + "=" * 60) - print("1. 미디어 목록 조회") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - media_list = await client.get_media_list(limit=5) - - print(f"\n📷 최근 게시물 ({len(media_list.data)}개)") - print("-" * 50) - - for i, media in enumerate(media_list.data, 1): - caption_preview = ( - media.caption[:40] + "..." - if media.caption and len(media.caption) > 40 - else media.caption or "(캡션 없음)" - ) - print(f"\n{i}. [{media.media_type}] {caption_preview}") - print(f" 🆔 ID: {media.id}") - print(f" ❤️ 좋아요: {media.like_count:,}") - print(f" 💬 댓글: {media.comments_count:,}") - print(f" 📅 게시일: {media.timestamp}") - print(f" 🔗 링크: {media.permalink}") - - # 페이지네이션 정보 - if media_list.paging and media_list.paging.next: - print(f"\n📄 다음 페이지 있음") - - except Exception as e: - print(f"❌ 에러: {e}") - - -async def example_get_media_detail(): - """미디어 상세 조회 예제""" - from poc.instagram1 import InstagramGraphClient - - print("\n" + "=" * 60) - print("2. 미디어 상세 조회") - print("=" * 60) - - try: - async with InstagramGraphClient() as client: - # 먼저 목록에서 첫 번째 미디어 ID 가져오기 - media_list = await client.get_media_list(limit=1) - if not media_list.data: - print("⚠️ 게시물이 없습니다.") - return - - media_id = media_list.data[0].id - print(f"\n조회할 미디어 ID: {media_id}") - - # 상세 조회 - media = await client.get_media(media_id) - - print(f"\n📷 미디어 상세 정보") - print("-" * 50) - print(f"🆔 ID: {media.id}") - print(f"📝 타입: {media.media_type}") - print(f"🖼️ URL: {media.media_url}") - print(f"📅 게시일: {media.timestamp}") - print(f"❤️ 좋아요: {media.like_count:,}") - print(f"💬 댓글: {media.comments_count:,}") - print(f"🔗 퍼머링크: {media.permalink}") - - if media.caption: - print(f"\n📝 캡션:") - print(f" {media.caption}") - - # 캐러셀인 경우 하위 미디어 표시 - if media.children: - print(f"\n📚 캐러셀 하위 미디어 ({len(media.children)}개)") - for j, child in enumerate(media.children, 1): - print(f" {j}. [{child.media_type}] {child.media_url}") - - except Exception as e: - print(f"❌ 에러: {e}") - - -async def example_publish_image(): - """이미지 게시 예제 (테스트용 - 실제 게시됨)""" - from poc.instagram1 import InstagramGraphClient, MediaPublishError - - print("\n" + "=" * 60) - print("3. 이미지 게시 (테스트)") - print("=" * 60) - - # ⚠️ 실제 게시되므로 주의 - # 테스트 이미지 URL (공개 접근 가능해야 함) - TEST_IMAGE_URL = "https://example.com/test-image.jpg" - TEST_CAPTION = "Test post from Instagram Graph API POC #test" - - print(f"\n⚠️ 이 예제는 실제로 게시물을 작성합니다!") - print(f" 이미지 URL: {TEST_IMAGE_URL}") - print(f" 캡션: {TEST_CAPTION}") - print(f"\n 테스트하려면 아래 코드의 주석을 해제하세요.") - - # try: - # async with InstagramGraphClient() as client: - # media = await client.publish_image( - # image_url=TEST_IMAGE_URL, - # caption=TEST_CAPTION, - # ) - # print(f"\n✅ 게시 완료!") - # print(f" 🆔 미디어 ID: {media.id}") - # print(f" 🔗 링크: {media.permalink}") - # except MediaPublishError as e: - # print(f"❌ 게시 실패: {e}") - # except Exception as e: - # print(f"❌ 에러: {e}") - - -async def example_publish_video(): - """비디오 게시 예제 (테스트용 - 실제 게시됨)""" - from poc.instagram1 import InstagramGraphClient, MediaPublishError - - print("\n" + "=" * 60) - print("4. 비디오/릴스 게시 (테스트)") - print("=" * 60) - - # ⚠️ 실제 게시되므로 주의 - TEST_VIDEO_URL = "https://example.com/test-video.mp4" - TEST_CAPTION = "Test video from Instagram Graph API POC #test" - - print(f"\n⚠️ 이 예제는 실제로 게시물을 작성합니다!") - print(f" 비디오 URL: {TEST_VIDEO_URL}") - print(f" 캡션: {TEST_CAPTION}") - print(f"\n 테스트하려면 아래 코드의 주석을 해제하세요.") - - # try: - # async with InstagramGraphClient() as client: - # media = await client.publish_video( - # video_url=TEST_VIDEO_URL, - # caption=TEST_CAPTION, - # share_to_feed=True, - # ) - # print(f"\n✅ 게시 완료!") - # print(f" 🆔 미디어 ID: {media.id}") - # print(f" 🔗 링크: {media.permalink}") - # except MediaPublishError as e: - # print(f"❌ 게시 실패: {e}") - # except Exception as e: - # print(f"❌ 에러: {e}") - - -async def example_publish_carousel(): - """캐러셀 게시 예제 (테스트용 - 실제 게시됨)""" - from poc.instagram1 import InstagramGraphClient, MediaPublishError - - print("\n" + "=" * 60) - print("5. 캐러셀(멀티 이미지) 게시 (테스트)") - print("=" * 60) - - # ⚠️ 실제 게시되므로 주의 - TEST_IMAGE_URLS = [ - "https://example.com/image1.jpg", - "https://example.com/image2.jpg", - "https://example.com/image3.jpg", - ] - TEST_CAPTION = "Test carousel from Instagram Graph API POC #test" - - print(f"\n⚠️ 이 예제는 실제로 게시물을 작성합니다!") - print(f" 이미지 수: {len(TEST_IMAGE_URLS)}개") - print(f" 캡션: {TEST_CAPTION}") - print(f"\n 테스트하려면 아래 코드의 주석을 해제하세요.") - - # try: - # async with InstagramGraphClient() as client: - # media = await client.publish_carousel( - # media_urls=TEST_IMAGE_URLS, - # caption=TEST_CAPTION, - # ) - # print(f"\n✅ 게시 완료!") - # print(f" 🆔 미디어 ID: {media.id}") - # print(f" 🔗 링크: {media.permalink}") - # except MediaPublishError as e: - # print(f"❌ 게시 실패: {e}") - # except Exception as e: - # print(f"❌ 에러: {e}") - - -async def main(): - """모든 미디어 예제 실행""" - print("\n📷 Instagram Graph API 미디어 예제") - print("=" * 60) - - # 미디어 목록 조회 - await example_get_media_list() - - # 미디어 상세 조회 - await example_get_media_detail() - - # 이미지 게시 (기본적으로 비활성화) - await example_publish_image() - - # 비디오 게시 (기본적으로 비활성화) - await example_publish_video() - - # 캐러셀 게시 (기본적으로 비활성화) - await example_publish_carousel() - - print("\n" + "=" * 60) - print("✅ 예제 완료") - print("=" * 60) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/poc/instagram1-difi/exceptions.py b/poc/instagram1-difi/exceptions.py deleted file mode 100644 index ef49565..0000000 --- a/poc/instagram1-difi/exceptions.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -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, - ) diff --git a/poc/instagram1-difi/models.py b/poc/instagram1-difi/models.py deleted file mode 100644 index 80c25bb..0000000 --- a/poc/instagram1-difi/models.py +++ /dev/null @@ -1,497 +0,0 @@ -""" -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() diff --git a/poc/instagram2-simple/__init__.py b/poc/instagram2-simple/__init__.py deleted file mode 100644 index f4c9cf2..0000000 --- a/poc/instagram2-simple/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -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" diff --git a/poc/instagram2-simple/client.py b/poc/instagram2-simple/client.py deleted file mode 100644 index 726559a..0000000 --- a/poc/instagram2-simple/client.py +++ /dev/null @@ -1,504 +0,0 @@ -""" -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 diff --git a/poc/instagram2-simple/exceptions.py b/poc/instagram2-simple/exceptions.py deleted file mode 100644 index 67f6125..0000000 --- a/poc/instagram2-simple/exceptions.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -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, - ) diff --git a/poc/instagram2-simple/main.py b/poc/instagram2-simple/main.py deleted file mode 100644 index 984f801..0000000 --- a/poc/instagram2-simple/main.py +++ /dev/null @@ -1,325 +0,0 @@ -""" -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()) diff --git a/poc/instagram2-simple/main_ori.py b/poc/instagram2-simple/main_ori.py deleted file mode 100644 index 05ca943..0000000 --- a/poc/instagram2-simple/main_ori.py +++ /dev/null @@ -1,329 +0,0 @@ -""" -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()) diff --git a/poc/instagram2-simple/manual.md b/poc/instagram2-simple/manual.md deleted file mode 100644 index 761eca6..0000000 --- a/poc/instagram2-simple/manual.md +++ /dev/null @@ -1,782 +0,0 @@ -# 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/) diff --git a/poc/instagram2-simple/models.py b/poc/instagram2-simple/models.py deleted file mode 100644 index b1bc97e..0000000 --- a/poc/instagram2-simple/models.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -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 diff --git a/poc/instagram2-simple/poc.md b/poc/instagram2-simple/poc.md deleted file mode 100644 index 4e947eb..0000000 --- a/poc/instagram2-simple/poc.md +++ /dev/null @@ -1,266 +0,0 @@ -# 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/)