finished test for instagram
parent
08d47a6990
commit
eff711e03e
|
|
@ -5,6 +5,8 @@ from app.database.session import engine
|
||||||
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
||||||
from app.lyric.api.lyrics_admin import LyricAdmin
|
from app.lyric.api.lyrics_admin import LyricAdmin
|
||||||
from app.song.api.song_admin import SongAdmin
|
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 app.video.api.video_admin import VideoAdmin
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
||||||
|
|
@ -35,4 +37,12 @@ def init_admin(
|
||||||
# 영상 관리
|
# 영상 관리
|
||||||
admin.add_view(VideoAdmin)
|
admin.add_view(VideoAdmin)
|
||||||
|
|
||||||
|
# 사용자 관리
|
||||||
|
admin.add_view(UserAdmin)
|
||||||
|
admin.add_view(RefreshTokenAdmin)
|
||||||
|
admin.add_view(SocialAccountAdmin)
|
||||||
|
|
||||||
|
# SNS 관리
|
||||||
|
admin.add_view(SNSUploadTaskAdmin)
|
||||||
|
|
||||||
return admin
|
return admin
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ async def create_db_tables():
|
||||||
from app.lyric.models import Lyric # noqa: F401
|
from app.lyric.models import Lyric # noqa: F401
|
||||||
from app.song.models import Song, SongTimestamp # noqa: F401
|
from app.song.models import Song, SongTimestamp # noqa: F401
|
||||||
from app.video.models import Video # noqa: F401
|
from app.video.models import Video # noqa: F401
|
||||||
|
from app.sns.models import SNSUploadTask # noqa: F401
|
||||||
|
|
||||||
# 생성할 테이블 목록
|
# 생성할 테이블 목록
|
||||||
tables_to_create = [
|
tables_to_create = [
|
||||||
|
|
@ -89,6 +90,7 @@ async def create_db_tables():
|
||||||
Song.__table__,
|
Song.__table__,
|
||||||
SongTimestamp.__table__,
|
SongTimestamp.__table__,
|
||||||
Video.__table__,
|
Video.__table__,
|
||||||
|
SNSUploadTask.__table__,
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Creating database tables...")
|
logger.info("Creating database tables...")
|
||||||
|
|
|
||||||
|
|
@ -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": "생성일시",
|
||||||
|
}
|
||||||
|
|
@ -160,16 +160,17 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"user_id",
|
"user_uuid",
|
||||||
"platform",
|
"platform",
|
||||||
"platform_username",
|
"platform_username",
|
||||||
"is_active",
|
"is_active",
|
||||||
"connected_at",
|
"is_deleted",
|
||||||
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"user_id",
|
"user_uuid",
|
||||||
"platform",
|
"platform",
|
||||||
"platform_user_id",
|
"platform_user_id",
|
||||||
"platform_username",
|
"platform_username",
|
||||||
|
|
@ -177,32 +178,34 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
||||||
"scope",
|
"scope",
|
||||||
"token_expires_at",
|
"token_expires_at",
|
||||||
"is_active",
|
"is_active",
|
||||||
"connected_at",
|
"is_deleted",
|
||||||
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
form_excluded_columns = ["connected_at", "updated_at", "user"]
|
form_excluded_columns = ["created_at", "updated_at", "user"]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
SocialAccount.user_id,
|
SocialAccount.user_uuid,
|
||||||
SocialAccount.platform,
|
SocialAccount.platform,
|
||||||
SocialAccount.platform_user_id,
|
SocialAccount.platform_user_id,
|
||||||
SocialAccount.platform_username,
|
SocialAccount.platform_username,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_default_sort = (SocialAccount.connected_at, True)
|
column_default_sort = (SocialAccount.created_at, True)
|
||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
SocialAccount.id,
|
SocialAccount.id,
|
||||||
SocialAccount.user_id,
|
SocialAccount.user_uuid,
|
||||||
SocialAccount.platform,
|
SocialAccount.platform,
|
||||||
SocialAccount.is_active,
|
SocialAccount.is_active,
|
||||||
SocialAccount.connected_at,
|
SocialAccount.is_deleted,
|
||||||
|
SocialAccount.created_at,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"user_id": "사용자 ID",
|
"user_uuid": "사용자 UUID",
|
||||||
"platform": "플랫폼",
|
"platform": "플랫폼",
|
||||||
"platform_user_id": "플랫폼 사용자 ID",
|
"platform_user_id": "플랫폼 사용자 ID",
|
||||||
"platform_username": "플랫폼 사용자명",
|
"platform_username": "플랫폼 사용자명",
|
||||||
|
|
@ -210,6 +213,7 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
||||||
"scope": "권한 범위",
|
"scope": "권한 범위",
|
||||||
"token_expires_at": "토큰 만료일시",
|
"token_expires_at": "토큰 만료일시",
|
||||||
"is_active": "활성화",
|
"is_active": "활성화",
|
||||||
"connected_at": "연동일시",
|
"is_deleted": "삭제됨",
|
||||||
|
"created_at": "생성일시",
|
||||||
"updated_at": "수정일시",
|
"updated_at": "수정일시",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ class SocialAccountCreateRequest(BaseModel):
|
||||||
refresh_token: Optional[str] = Field(None, description="OAuth 리프레시 토큰")
|
refresh_token: Optional[str] = Field(None, description="OAuth 리프레시 토큰")
|
||||||
token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시")
|
token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시")
|
||||||
scope: Optional[str] = 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_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들")
|
||||||
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
||||||
|
|
||||||
|
|
@ -33,14 +33,11 @@ class SocialAccountCreateRequest(BaseModel):
|
||||||
"platform": "instagram",
|
"platform": "instagram",
|
||||||
"access_token": "IGQWRPcG...",
|
"access_token": "IGQWRPcG...",
|
||||||
"refresh_token": None,
|
"refresh_token": None,
|
||||||
"token_expires_at": "2026-03-15T10:30:00",
|
"token_expires_at": None,
|
||||||
"scope": "instagram_basic,instagram_content_publish",
|
"scope": None,
|
||||||
"platform_user_id": "17841400000000000",
|
"platform_user_id": None,
|
||||||
"platform_username": "my_instagram_account",
|
"platform_username": None,
|
||||||
"platform_data": {
|
"platform_data": None,
|
||||||
"business_account_id": "17841400000000000",
|
|
||||||
"facebook_page_id": "123456789"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +73,7 @@ class SocialAccountResponse(BaseModel):
|
||||||
|
|
||||||
account_id: int = Field(..., validation_alias="id", description="소셜 계정 ID")
|
account_id: int = Field(..., validation_alias="id", description="소셜 계정 ID")
|
||||||
platform: Platform = Field(..., description="플랫폼 구분")
|
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_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들")
|
||||||
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
||||||
scope: Optional[str] = Field(None, description="허용된 권한 범위")
|
scope: Optional[str] = Field(None, description="허용된 권한 범위")
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ URL 경로 형식:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -129,6 +130,33 @@ class AzureBlobUploader:
|
||||||
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
|
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
|
||||||
return self._last_public_url
|
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:
|
def _build_upload_url(self, category: str, file_name: str) -> str:
|
||||||
"""업로드 URL 생성 (SAS 토큰 포함)"""
|
"""업로드 URL 생성 (SAS 토큰 포함)"""
|
||||||
# SAS 토큰 앞뒤의 ?, ', " 제거
|
# SAS 토큰 앞뒤의 ?, ', " 제거
|
||||||
|
|
@ -238,8 +266,8 @@ class AzureBlobUploader:
|
||||||
Returns:
|
Returns:
|
||||||
bool: 업로드 성공 여부
|
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)
|
upload_url = self._build_upload_url(category, file_name)
|
||||||
self._last_public_url = self._build_public_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")
|
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
||||||
print(uploader.public_url)
|
print(uploader.public_url)
|
||||||
"""
|
"""
|
||||||
# 확장자가 없으면 .mp3 추가
|
# 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp3 추가
|
||||||
|
file_name = self._sanitize_filename(file_name)
|
||||||
if not Path(file_name).suffix:
|
if not Path(file_name).suffix:
|
||||||
file_name = f"{file_name}.mp3"
|
file_name = f"{file_name}.mp3"
|
||||||
|
|
||||||
|
|
@ -363,7 +392,8 @@ class AzureBlobUploader:
|
||||||
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
||||||
print(uploader.public_url)
|
print(uploader.public_url)
|
||||||
"""
|
"""
|
||||||
# 확장자가 없으면 .mp4 추가
|
# 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp4 추가
|
||||||
|
file_name = self._sanitize_filename(file_name)
|
||||||
if not Path(file_name).suffix:
|
if not Path(file_name).suffix:
|
||||||
file_name = f"{file_name}.mp4"
|
file_name = f"{file_name}.mp4"
|
||||||
|
|
||||||
|
|
@ -430,9 +460,13 @@ class AzureBlobUploader:
|
||||||
success = await uploader.upload_image_bytes(content, "my_image.png")
|
success = await uploader.upload_image_bytes(content, "my_image.png")
|
||||||
print(uploader.public_url)
|
print(uploader.public_url)
|
||||||
"""
|
"""
|
||||||
|
# Content-Type 결정을 위해 먼저 확장자 추출
|
||||||
extension = Path(file_name).suffix.lower()
|
extension = Path(file_name).suffix.lower()
|
||||||
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
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)
|
upload_url = self._build_upload_url("image", file_name)
|
||||||
self._last_public_url = self._build_public_url("image", file_name)
|
self._last_public_url = self._build_public_url("image", file_name)
|
||||||
log_prefix = "upload_image_bytes"
|
log_prefix = "upload_image_bytes"
|
||||||
|
|
|
||||||
|
|
@ -551,23 +551,15 @@ async def get_video_status(
|
||||||
|
|
||||||
if video and video.status != "completed":
|
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 업데이트 → 임시 파일 삭제
|
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
|
||||||
logger.info(
|
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(
|
background_tasks.add_task(
|
||||||
download_and_upload_video_to_blob,
|
download_and_upload_video_to_blob,
|
||||||
task_id=video.task_id,
|
task_id=video.task_id,
|
||||||
video_url=video_url,
|
video_url=video_url,
|
||||||
store_name=store_name,
|
creatomate_render_id=creatomate_render_id,
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
)
|
)
|
||||||
elif video and video.status == "completed":
|
elif video and video.status == "completed":
|
||||||
|
|
|
||||||
|
|
@ -105,27 +105,25 @@ async def _download_video(url: str, task_id: str) -> bytes:
|
||||||
async def download_and_upload_video_to_blob(
|
async def download_and_upload_video_to_blob(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
video_url: str,
|
video_url: str,
|
||||||
store_name: str,
|
creatomate_render_id: str,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||||
|
|
||||||
|
파일명은 creatomate_render_id를 사용하여 고유성을 보장합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: 프로젝트 task_id
|
task_id: 프로젝트 task_id
|
||||||
video_url: 다운로드할 영상 URL
|
video_url: 다운로드할 영상 URL
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
creatomate_render_id: Creatomate API 렌더 ID (파일명으로 사용)
|
||||||
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
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
|
temp_file_path: Path | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
# creatomate_render_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요)
|
||||||
safe_store_name = "".join(
|
file_name = f"{creatomate_render_id}.mp4"
|
||||||
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"
|
|
||||||
|
|
||||||
# 임시 저장 경로 생성
|
# 임시 저장 경로 생성
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
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(
|
async def download_and_upload_video_by_creatomate_render_id(
|
||||||
creatomate_render_id: str,
|
creatomate_render_id: str,
|
||||||
video_url: str,
|
video_url: str,
|
||||||
store_name: str,
|
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||||
|
|
||||||
|
파일명은 creatomate_render_id를 사용하여 고유성을 보장합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
creatomate_render_id: Creatomate API 렌더 ID
|
creatomate_render_id: Creatomate API 렌더 ID (파일명으로도 사용)
|
||||||
video_url: 다운로드할 영상 URL
|
video_url: 다운로드할 영상 URL
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
|
||||||
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
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
|
temp_file_path: Path | None = None
|
||||||
task_id: str | 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
|
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}")
|
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
|
||||||
|
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
# creatomate_render_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요)
|
||||||
safe_store_name = "".join(
|
file_name = f"{creatomate_render_id}.mp4"
|
||||||
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"
|
|
||||||
|
|
||||||
# 임시 저장 경로 생성
|
# 임시 저장 경로 생성
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
|
|
|
||||||
558
insta_plan.md
558
insta_plan.md
|
|
@ -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로 에러 타입별 처리 가능
|
|
||||||
10
main.py
10
main.py
|
|
@ -32,7 +32,7 @@ tags_metadata = [
|
||||||
1. `GET /user/auth/kakao/login` - 카카오 로그인 URL 획득
|
1. `GET /user/auth/kakao/login` - 카카오 로그인 URL 획득
|
||||||
2. 사용자를 auth_url로 리다이렉트 → 카카오 로그인
|
2. 사용자를 auth_url로 리다이렉트 → 카카오 로그인
|
||||||
3. 카카오에서 인가 코드(code) 발급
|
3. 카카오에서 인가 코드(code) 발급
|
||||||
4. `POST /user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급
|
4. `GET /user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급 (카카오 리다이렉트)
|
||||||
5. 이후 API 호출 시 `Authorization: Bearer {access_token}` 헤더 사용
|
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 반환
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,15 @@ logging.basicConfig(
|
||||||
logger = logging.getLogger(__name__)
|
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"
|
VIDEO_CAPTION = "Test video from Instagram POC #test"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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/)
|
|
||||||
|
|
@ -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/)
|
|
||||||
|
|
@ -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` 명령으로 개발 에이전트를 호출하여 구현을 진행합니다.
|
|
||||||
|
|
@ -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` 에이전트에 의해 자동 생성되었습니다.*
|
|
||||||
|
|
@ -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/)
|
|
||||||
|
|
@ -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` 에이전트에 의해 자동 생성되었습니다.*
|
|
||||||
|
|
@ -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` 에이전트에 의해 자동 생성되었습니다.*
|
|
||||||
|
|
@ -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"
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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()
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
"""
|
|
||||||
Instagram Graph API 예제 모듈
|
|
||||||
|
|
||||||
각 기능별 실행 가능한 예제를 제공합니다.
|
|
||||||
"""
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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/)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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/)
|
|
||||||
Loading…
Reference in New Issue