Skill: lifeissea/instagram-api (version 1.1.1)

--- name: Instagram API description: Post to Instagram (Feed, Story, Reels, Carousel) and Threads using the official Meta Graph API. Requires Imgur for media hosting. version: 1.1.1 author: raon requires_env: - INSTAGRAM_ACCESS_TOKEN - INSTAGRAM_BUSINESS_ACCOUNT_ID - IMGUR_CLIENT_ID --- # instagram-api Meta Graph API를 사용해 Instagram과 Threads에 직접 포스팅하는 스킬. 미디어 호스팅은 Imgur API를 사용 (이미지/영상 공개 URL 생성). --- ## Imgur Client ID 발급 Instagram Graph API는 공개 URL로만 미디어를 업로드할 수 있어 Imgur가 필요합니다. 1. https://api.imgur.com/oauth2/addclient 접속 2. **Application name**: 원하는 이름 (예: `raon-instagram`) 3. **Authorization type**: `Anonymous usage without user authorization` 선택 4. **Authorization callback URL**: `https://localhost` (Anonymous이므로 형식만 맞추면 됨) 5. 이메일 입력 후 제출 → **Client ID** 확인 6. 환경변수 설정: ```bash export IMGUR_CLIENT_ID="your_client_id_here" ``` --- ## 환경변수 설정 ```bash # ~/.openclaw/.env 또는 ~/.zshrc에 추가 export INSTAGRAM_ACCESS_TOKEN="your_token_here" export INSTAGRAM_BUSINESS_ACCOUNT_ID="your_account_id_here" # Threads (선택) export THREADS_ACCESS_TOKEN="your_threads_token_here" export THREADS_USER_ID="your_threads_user_id_here" # Imgur (이미지 호스팅용 — 피드/릴스 업로드 시 필요) export IMGUR_CLIENT_ID="your_imgur_client_id_here" ``` --- ## Meta Graph API 토큰 발급 1. [Meta for Developers](https://developers.facebook.com/) 접속 2. 앱 생성 → Business 유형 선택 3. Instagram Graph API 제품 추가 4. **권한 요청**: - `instagram_basic` - `instagram_content_publish` - `pages_read_engagement` 5. **Access Token** 발급: - Graph API Explorer: https://developers.facebook.com/tools/explorer/ - 장기 토큰(Long-lived token)으로 교환: 60일 유효 6. **Business Account ID** 확인: ```bash curl "https://graph.facebook.com/v21.0/me/accounts?access_token=YOUR_TOKEN" ``` > 💡 **Imgur Client ID**: https://api.imgur.com/oauth2/addclient (Anonymous usage 선택) --- ## 스크립트 사용법 ### 피드 포스팅 ```bash bash scripts/post-feed.sh <이미지경로> <캡션파일> # 예시 bash scripts/post-feed.sh ./photo.jpg ./caption.txt ``` ### 스토리 포스팅 ```bash bash scripts/post-story.sh <이미지경로> # 예시 bash scripts/post-story.sh ./story.jpg ``` ### 릴스 포스팅 ```bash bash scripts/post-reels.sh <영상경로> <캡션파일> # 예시 bash scripts/post-reels.sh ./reel.mp4 ./caption.txt ``` ### 캐러셀 포스팅 ```bash bash scripts/post-carousel.sh <캡션파일> <이미지1> <이미지2> [이미지3...] # 예시 bash scripts/post-carousel.sh ./caption.txt ./img1.jpg ./img2.jpg ./img3.jpg ``` ### Threads 포스팅 ```bash bash scripts/post-threads.sh <캡션파일> [이미지URL] # 예시 (텍스트만) bash scripts/post-threads.sh ./caption.txt # 예시 (이미지 포함) bash scripts/post-threads.sh ./caption.txt "https://example.com/image.jpg" ``` --- ## 파일 구조 ``` instagram-api/ ├── SKILL.md # 이 파일 └── scripts/ ├── post-feed.sh # 피드 포스팅 ├── post-story.sh # 스토리 포스팅 ├── post-reels.sh # 릴스 포스팅 ├── post-carousel.sh # 캐러셀 포스팅 └── post-threads.sh # Threads 포스팅 ``` --- ## 주의사항 - Instagram은 **공개 URL**로만 미디어 업로드 가능 (로컬 파일 직접 업로드 불가) - 이 스킬은 Imgur를 통해 임시 공개 URL 생성 - 릴스 동영상 처리에는 수분 소요될 수 있음 - API 호출 실패 시 `~/logs/sns/` 로그 확인

path: scripts/post-carousel.sh
content: #!/bin/bash
# Instagram Graph API — 캐러셀 포스팅
# 사용법: bash post-carousel.sh <캡션파일> <이미지1> <이미지2> [이미지3...]
#
# 환경변수 필수:
#   INSTAGRAM_ACCESS_TOKEN        - Meta Graph API 토큰
#   INSTAGRAM_BUSINESS_ACCOUNT_ID - 비즈니스 계정 ID
#   IMGUR_CLIENT_ID               - Imgur Client ID (이미지 호스팅)

set -euo pipefail

LOG_DIR="${LOG_DIR:-$HOME/logs/sns}"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/instagram-carousel.log"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
err() { log "❌ ERROR: $*"; exit 1; }

# ── 인자 확인 ──────────────────────────────────────────────
if [[ $# -lt 3 ]]; then
  echo "사용법: $0 <캡션파일> <이미지1> <이미지2> [이미지3...]"
  exit 1
fi

CAPTION_FILE="$1"
shift
IMAGES=("$@")

[[ -f "$CAPTION_FILE" ]] || err "캡션 파일 없음: $CAPTION_FILE"
[[ ${#IMAGES[@]} -ge 2 ]] || err "캐러셀은 최소 2개 이미지 필요"
[[ ${#IMAGES[@]} -le 10 ]] || err "캐러셀은 최대 10개 이미지"

for img in "${IMAGES[@]}"; do
  [[ -f "$img" ]] || err "이미지 파일 없음: $img"
done

# ── 환경변수 로드 ──────────────────────────────────────────

[REDACTED_SECRET] 환경변수 필요}"
IG_ID="${INSTAGRAM_BUSINESS_ACCOUNT_ID:?INSTAGRAM_BUSINESS_ACCOUNT_ID 환경변수 필요}"
IMGUR_ID="${IMGUR_CLIENT_ID:?IMGUR_CLIENT_ID 환경변수 필요}"
API_BASE="https://graph.facebook.com/v21.0"

CAPTION=$(cat "$CAPTION_FILE")
log "🎠 캐러셀 포스팅 시작: ${#IMAGES[@]}장 이미지"

# ── Step 1: 각 이미지 Imgur 업로드 + 캐러셀 아이템 컨테이너 생성 ──
CONTAINER_IDS=()

for i in "${!IMAGES[@]}"; do
  img="${IMAGES[$i]}"
  log "  [$((i+1))/${#IMAGES[@]}] 업로드: $img"

  # Imgur 업로드 (Python urllib)
  IMGUR_RESP=$(python3 - "${img}" "$IMGUR_ID" << 'PYEOF'
import sys, json, urllib.request, urllib.parse, base64
img_path, client_id = sys.argv[1], sys.argv[2]
with open(img_path, "rb") as f:
    img_b64 = base64.b64encode(f.read()).decode()
data = urllib.parse.urlencode({"image": img_b64, "type": "base64"}).encode()
req = urllib.request.Request("https://api.imgur.com/3/image", data=data, headers={"Authorization": f"Client-ID {client_id}"})
with urllib.request.urlopen(req) as resp:
    print(resp.read().decode())
PYEOF
)

  IMG_URL=$(echo "$IMGUR_RESP" | python3 -c \
    "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('link',''))" 2>/dev/null)

  [[ -n "$IMG_URL" && "$IMG_URL" == http* ]] || err "Imgur 업로드 실패 ($img): $IMGUR_RESP"
  log "  ✅ URL: $IMG_URL"

  ITEM_RESP=$(python3 - "$API_BASE" "$IG_ID" "$IMG_URL" "$TOKEN" << 'PYEOF'
import urllib.request, urllib.parse, json, sys

api, ig_id, image_url, [REDACTED_SECRET]
params = urllib.parse.urlencode({
    "image_url": image_url,
    "is_carousel_item": "true",
    "access_token": token
}).encode()
req = urllib.request.Request(f"{api}/{ig_id}/media", data=params, method="POST")
try:
    resp = urllib.request.urlopen(req)
    print(resp.read().decode())
except Exception as e:
    if hasattr(e, 'read'):
        print(e.read().decode())
    else:
        print(json.dumps({"error": str(e)}))
PYEOF
)

  ITEM_ID=$(echo "$ITEM_RESP" | python3 -c \
    "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
  [[ -n "$ITEM_ID" ]] || err "캐러셀 아이템 생성 실패: $ITEM_RESP"
  log "  ✅ 아이템 컨테이너: $ITEM_ID"

  CONTAINER_IDS+=("$ITEM_ID")
  sleep 1
done

# ── Step 2: 캐러셀 컨테이너 생성 ─────────────────────────
log "🎠 캐러셀 컨테이너 생성 중..."

# children 파라미터 배열 구성
CHILDREN_JSON=$(python3 - "${CONTAINER_IDS[@]}" << 'PYEOF'
import sys, json
ids = sys.argv[1:]
# URL 인코딩용 children 쌍 출력
for i, cid in enumerate(ids):
    print(f"children[{i}]={cid}")
PYEOF
)

CAROUSEL_RESP=$(python3 - "$API_BASE" "$IG_ID" "$CAPTION" "$TOKEN" "${CONTAINER_IDS[@]}" << 'PYEOF'
import urllib.request, urllib.parse, json, sys

api, ig_id, caption, [REDACTED_SECRET], sys.argv[2], sys.argv[3], sys.argv[4]
children = sys.argv[5:]

params = {
    "media_type": "CAROUSEL",
    "caption": caption,
    "access_token": token,
}
for i, cid in enumerate(children):
    params[f"children[{i}]"] = cid

encoded = urllib.parse.urlencode(params).encode()
req = urllib.request.Request(f"{api}/{ig_id}/media", data=encoded, method="POST")
try:
    resp = urllib.request.urlopen(req)
    print(resp.read().decode())
except Exception as e:
    if hasattr(e, 'read'):
        print(e.read().decode())
    else:
        print(json.dumps({"error": str(e)}))
PYEOF
)

CAROUSEL_ID=$(echo "$CAROUSEL_RESP" | python3 -c \
  "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[[ -n "$CAROUSEL_ID" ]] || err "캐러셀 컨테이너 생성 실패: $CAROUSEL_RESP"
log "✅ 캐러셀 컨테이너: $CAROUSEL_ID"

# ── Step 3: 게시 ──────────────────────────────────────────
log "🚀 캐러셀 게시 중..."
PUBLISH_RESP=$(python3 - "$API_BASE" "$IG_ID" "$CAROUSEL_ID" "$TOKEN" << 'PYEOF'
import urllib.request, urllib.parse, json, sys

api, ig_id, container_id, [REDACTED_SECRET]
params = urllib.parse.urlencode({
    "creation_id": container_id,
    "access_token": token
}).encode()
req = urllib.request.Request(f"{api}/{ig_id}/media_publish", data=params, method="POST")
try:
    resp = urllib.request.urlopen(req)
    print(resp.read().decode())
except Exception as e:
    if hasattr(e, 'read'):
        print(e.read().decode())
    else:
        print(json.dumps({"error": str(e)}))
PYEOF
)

MEDIA_ID=$(echo "$PUBLISH_RESP" | python3 -c \
  "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[[ -n "$MEDIA_ID" ]] || err "캐러셀 게시 실패: $PUBLISH_RESP"

log "✅ 캐러셀 게시 완료! media_id=$MEDIA_ID (${#IMAGES[@]}장)"
echo "$MEDIA_ID"


path: scripts/post-feed.sh
content: #!/bin/bash
# Instagram Graph API — 피드 포스팅
# 사용법: bash post-feed.sh <이미지경로> <캡션파일>
#
# 환경변수 필수:
#   INSTAGRAM_ACCESS_TOKEN        - Meta Graph API 토큰
#   INSTAGRAM_BUSINESS_ACCOUNT_ID - 비즈니스 계정 ID
#   IMGUR_CLIENT_ID               - Imgur Client ID (이미지 호스팅)

set -euo pipefail

LOG_DIR="${LOG_DIR:-$HOME/logs/sns}"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/instagram-feed.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
err() { log "❌ ERROR: $*"; exit 1; }

IMAGE_PATH="${1:?사용법: $0 <이미지경로> <캡션파일>}"
CAPTION_FILE="${2:?사용법: $0 <이미지경로> <캡션파일>}"
[[ -f "$IMAGE_PATH" ]]   || err "이미지 파일 없음: $IMAGE_PATH"
[[ -f "$CAPTION_FILE" ]] || err "캡션 파일 없음: $CAPTION_FILE"


[REDACTED_SECRET] 환경변수 필요}"
IG_ID="${INSTAGRAM_BUSINESS_ACCOUNT_ID:?INSTAGRAM_BUSINESS_ACCOUNT_ID 환경변수 필요}"
IMGUR_ID="${IMGUR_CLIENT_ID:?IMGUR_CLIENT_ID 환경변수 필요}"
API_BASE="https://graph.facebook.com/v21.0"
CAPTION=$(cat "$CAPTION_FILE")

log "📝 피드 포스팅 시작: $IMAGE_PATH"

# Step 1: Imgur 업로드 (Python urllib 사용)
log "📤 Imgur 업로드 중..."
IMAGE_URL=$(python3 - "$IMAGE_PATH" "$IMGUR_ID" << 'PYEOF'
import sys, json, urllib.request, urllib.parse, base64
img_path, client_id = sys.argv[1], sys.argv[2]
with open(img_path, "rb") as f:
    img_b64 = base64.b64encode(f.read()).decode()
data = urllib.parse.urlencode({"image": img_b64, "type": "base64"}).encode()
req = urllib.request.Request(
    "https://api.imgur.com/3/image",
    data=data,
    headers={"Authorization": f"Client-ID {client_id}"}
)
with urllib.request.urlopen(req) as resp:
    d = json.load(resp)
print(d.get("data", {}).get("link", ""))
PYEOF
)

[[ -n "$IMAGE_URL" && "$IMAGE_URL" == http* ]] || err "Imgur 업로드 실패"
log "✅ Imgur URL: $IMAGE_URL"

# Step 2: 미디어 컨테이너 생성 + 게시
log "🖼️  미디어 컨테이너 생성 중..."
python3 - "$API_BASE" "$IG_ID" "$IMAGE_URL" "$CAPTION" "$TOKEN" << 'PYEOF'
import urllib.request, urllib.parse, json, sys
api, ig_id, image_url, caption, [REDACTED_SECRET]

# 컨테이너 생성
params = urllib.parse.urlencode({
    "image_url": image_url, "caption": caption, "access_token": token
}).encode()
req = urllib.request.Request(f"{api}/{ig_id}/media", data=params)
with urllib.request.urlopen(req) as resp:
    container_id = json.load(resp)["id"]

# 게시
params = urllib.parse.urlencode({
    "creation_id": container_id, "access_token": token
}).encode()
req = urllib.request.Request(f"{api}/{ig_id}/media_publish", data=params)
with urllib.request.urlopen(req) as resp:
    result = json.load(resp)
print(f"✅ 포스팅 완료: {result.get('id', 'unknown')}")
PYEOF


path: scripts/post-reels.sh
content: #!/bin/bash
# Instagram Graph API — 릴스 포스팅
# 사용법: bash post-reels.sh <영상경로> <캡션파일>
#
# 환경변수 필수:
#   INSTAGRAM_ACCESS_TOKEN        - Meta Graph API 토큰
#   INSTAGRAM_BUSINESS_ACCOUNT_ID - 비즈니스 계정 ID
#   IMGUR_CLIENT_ID               - Imgur Client ID (영상 임시 호스팅)
#
# 참고: 릴스는 동영상 처리에 수분 소요될 수 있습니다.

set -euo pipefail

LOG_DIR="${LOG_DIR:-$HOME/logs/sns}"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/instagram-reels.log"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
err() { log "❌ ERROR: $*"; exit 1; }

# ── 인자 확인 ──────────────────────────────────────────────
VIDEO_PATH="${1:?사용법: $0 <영상경로> <캡션파일>}"
CAPTION_FILE="${2:?사용법: $0 <영상경로> <캡션파일>}"

[[ -f "$VIDEO_PATH" ]]   || err "영상 파일 없음: $VIDEO_PATH"
[[ -f "$CAPTION_FILE" ]] || err "캡션 파일 없음: $CAPTION_FILE"

# ── 환경변수 로드 ──────────────────────────────────────────

[REDACTED_SECRET] 환경변수 필요}"
IG_ID="${INSTAGRAM_BUSINESS_ACCOUNT_ID:?INSTAGRAM_BUSINESS_ACCOUNT_ID 환경변수 필요}"
IMGUR_ID="${IMGUR_CLIENT_ID:?IMGUR_CLIENT_ID 환경변수 필요}"
API_BASE="https://graph.facebook.com/v21.0"

CAPTION=$(cat "$CAPTION_FILE")
log "🎬 릴스 포스팅 시작: $VIDEO_PATH"

# ── Step 1: 동영상 업로드 (Imgur 비디오) ─────────────────
log "📤 동영상 업로드 중... (시간이 걸릴 수 있습니다)"
# Imgur public upload API (no OAuth required)
VIDEO_URL=$(python3 - "$VIDEO_PATH" "$IMGUR_ID" << 'PYEOF'
import sys, json, urllib.request, urllib.parse
video_path, client_id = sys.argv[1], sys.argv[2]
with open(video_path, "rb") as f:
    video_data = f.read()
boundary = b"----FormBoundary"
body = boundary + b"\r\nContent-Disposition: form-data; name=\"video\"; filename=\"video.mp4\"\r\n\r\n" + video_data + b"\r\n" + boundary + b"--"
req = urllib.request.Request(
    "https://api.imgur.com/3/video",
    data=body,
    headers={"Authorization": f"Client-ID {client_id}", "Content-Type": f"multipart/form-data; boundary={boundary.decode().lstrip('-').strip()}"}
)
with urllib.request.urlopen(req) as resp:
    d = json.load(resp)
print(d.get("data", {}).get("link", ""))
PYEOF
)

[[ -n "$VIDEO_URL" && "$VIDEO_URL" == http* ]] || err "동영상 업로드 실패: $IMGUR_RESP"
log "✅ 업로드 URL: $VIDEO_URL"

# ── Step 2: 릴스 컨테이너 생성 ───────────────────────────
log "🎞️  릴스 컨테이너 생성 중..."
CONTAINER_RESP=$(python3 - "$API_BASE" "$IG_ID" "$VIDEO_URL" "$CAPTION" "$TOKEN" << 'PYEOF'
import urllib.request, urllib.parse, json, sys

api, ig_id, video_url, caption, [REDACTED_SECRET]
params = urllib.parse.urlencode({
    "media_type": "REELS",
    "video_url": video_url,
    "caption": caption,
    "access_token": token
}).encode()
req = urllib.request.Request(f"{api}/{ig_id}/media", data=params, method="POST")
try:
    resp = urllib.request.urlopen(req)
    print(resp.read().decode())
except Exception as e:
    if hasattr(e, 'read'):
        print(e.read().decode())
    else:
        print(json.dumps({"error": str(e)}))
PYEOF
)

CONTAINER_ID=$(echo "$CONTAINER_RESP" | python3 -c \
  "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[[ -n "$CONTAINER_ID" ]] || err "컨테이너 생성 실패: $CONTAINER_RESP"
log "✅ 컨테이너: $CONTAINER_ID"

# ── Step 3: 처리 상태 대기 ───────────────────────────────
log "⏳ 동영상 처리 대기 중..."
sleep 10

MAX_ATTEMPTS=24  # 최대 2분 대기
for attempt in $(seq 1 $MAX_ATTEMPTS); do
  STATUS_RESP=$(python3 - "$API_BASE" "$CONTAINER_ID" "$TOKEN" << 'PYEOF'
import urllib.request, json, sys

api, container_id, [REDACTED_SECRET]
url = f"{api}/{container_id}?fields=status_code,status&access_token={token}"
try:
    resp = urllib.request.urlopen(url)
    print(resp.read().decode())
except Exception as e:
    if hasattr(e, 'read'):
        print(e.read().decode())
    else:
        print(json.dumps({"status_code": "ERROR", "error": str(e)}))
PYEOF
)

  STATUS=$(echo "$STATUS_RESP" | python3 -c \
    "import sys,json; print(json.load(sys.stdin).get('status_code','IN_PROGRESS'))" 2>/dev/null)

  log "  처리 상태: $STATUS (시도 $attempt/$MAX_ATTEMPTS)"

  if [[ "$STATUS" == "FINISHED" ]]; then
    break
  elif [[ "$STATUS" == "ERROR" ]]; then
    err "동영상 처리 오류: $STATUS_RESP"
  fi

  sleep 5
done

# ── Step 4: 게시 ──────────────────────────────────────────
log "🚀 릴스 게시 중..."
PUBLISH_RESP=$(python3 - "$API_BASE" "$IG_ID" "$CONTAINER_ID" "$TOKEN" << 'PYEOF'
import urllib.request, urllib.parse, json, sys

api, ig_id, container_id, [REDACTED_SECRET]
params = urllib.parse.urlencode({
    "creation_id": container_id,
    "access_token": token
}).encode()
req = urllib.request.Request(f"{api}/{ig_id}/media_publish", data=params, method="POST")
try:
    resp = urllib.request.urlopen(req)
    print(resp.read().decode())
except Exception as e:
    if hasattr(e, 'read'):
        print(e.read().decode())
    else:
        print(json.dumps({"error": str(e)}))
PYEOF
)

MEDIA_ID=$(echo "$PUBLISH_RESP" | python3 -c \
  "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[[ -n "$MEDIA_ID" ]] || err "릴스 게시 실패: $PUBLISH_RESP"

log "✅ 릴스 게시 완료! media_id=$MEDIA_ID"
echo "$MEDIA_ID"


path: scripts/post-story.sh
content: #!/bin/bash
# Instagram Graph API — 스토리 포스팅
# 사용법: bash post-story.sh <이미지경로>

set -euo pipefail
LOG_DIR="${LOG_DIR:-$HOME/logs/sns}"; mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/instagram-story.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
err() { log "❌ ERROR: $*"; exit 1; }

IMAGE_PATH="${1:?사용법: $0 <이미지경로>}"
[[ -f "$IMAGE_PATH" ]] || err "이미지 파일 없음: $IMAGE_PATH"


[REDACTED_SECRET] 환경변수 필요}"
IG_ID="${INSTAGRAM_BUSINESS_ACCOUNT_ID:?INSTAGRAM_BUSINESS_ACCOUNT_ID 환경변수 필요}"
IMGUR_ID="${IMGUR_CLIENT_ID:?IMGUR_CLIENT_ID 환경변수 필요}"
API_BASE="https://graph.facebook.com/v21.0"

log "📤 스토리 업로드 시작: $IMAGE_PATH"

# Imgur 업로드 (Python urllib)
IMAGE_URL=$(python3 - "$IMAGE_PATH" "$IMGUR_ID" << 'PYEOF'
import sys, json, urllib.request, urllib.parse, base64
img_path, client_id = sys.argv[1], sys.argv[2]
with open(img_path, "rb") as f:
    img_b64 = base64.b64encode(f.read()).decode()
data = urllib.parse.urlencode({"image": img_b64, "type": "base64"}).encode()
req = urllib.request.Request(
    "https://api.imgur.com/3/image",
    data=data,
    headers={"Authorization": f"Client-ID {client_id}"}
)
with urllib.request.urlopen(req) as resp:
    d = json.load(resp)
print(d.get("data", {}).get("link", ""))
PYEOF
)

[[ -n "$IMAGE_URL" && "$IMAGE_URL" == http* ]] || err "Imgur 업로드 실패"
log "✅ Imgur URL: $IMAGE_URL"

# 스토리 게시
python3 - "$API_BASE" "$IG_ID" "$IMAGE_URL" "$TOKEN" << 'PYEOF'
import urllib.request, urllib.parse, json, sys
api, ig_id, image_url, [REDACTED_SECRET]

params = urllib.parse.urlencode({
    "image_url": image_url, "media_type": "STORIES", "access_token": token
}).encode()
req = urllib.request.Request(f"{api}/{ig_id}/media", data=params)
with urllib.request.urlopen(req) as resp:
    container_id = json.load(resp)["id"]

params = urllib.parse.urlencode({
    "creation_id": container_id, "access_token": token
}).encode()
req = urllib.request.Request(f"{api}/{ig_id}/media_publish", data=params)
with urllib.request.urlopen(req) as resp:
    result = json.load(resp)
print(f"✅ 스토리 게시 완료: {result.get('id', 'unknown')}")
PYEOF


path: scripts/post-threads.sh
content: #!/bin/bash
# Threads API — 게시글 포스팅
# 사용법: bash post-threads.sh <캡션파일> [이미지URL]
#
# 환경변수 필수:
#   THREADS_ACCESS_TOKEN - Threads API 토큰
#   THREADS_USER_ID      - Threads 유저 ID
#
# 환경변수 선택:
#   INSTAGRAM_ACCESS_TOKEN        - 대체 토큰 (THREADS_ACCESS_TOKEN 없을 때)

set -euo pipefail

LOG_DIR="${LOG_DIR:-$HOME/logs/sns}"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/threads-post.log"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
err() { log "❌ ERROR: $*"; exit 1; }
if [ -f ~/.openclaw/.env ]; then source ~/.openclaw/.env; fi


# ── 인자 확인 ──────────────────────────────────────────────
CAPTION_FILE="${1:?사용법: $0 <캡션파일> [이미지URL]}"
IMAGE_URL="${2:-}"

[[ -f "$CAPTION_FILE" ]] || err "캡션 파일 없음: $CAPTION_FILE"

# ── 환경변수 로드 ──────────────────────────────────────────

# Threads 전용 토큰 우선, 없으면 Instagram 토큰 사용
[REDACTED_SECRET]"
[[ -n "$TOKEN" ]] || err "THREADS_ACCESS_TOKEN 또는 INSTAGRAM_ACCESS_TOKEN 환경변수 필요"

USER_ID="${THREADS_USER_ID:?THREADS_USER_ID 환경변수 필요}"
API_BASE="https://graph.threads.net/v1.0"

CAPTION=$(python3 /Users/tomas/.openclaw/workspace/scripts/utils/clean_md.py --threads < "$CAPTION_FILE")
log "🧵 Threads 포스팅 시작 (캡션 ${#CAPTION}자)"

# ── Step 1: 컨테이너 생성 ─────────────────────────────────
if [[ -n "$IMAGE_URL" ]]; then
  log "🖼️  이미지 컨테이너 생성 중..."
  MEDIA_TYPE="IMAGE"
else
  log "📝 텍스트 컨테이너 생성 중..."
  MEDIA_TYPE="TEXT"
fi

CONTAINER_RESP=$(python3 - "$API_BASE" "$USER_ID" "$MEDIA_TYPE" "$CAPTION" "$TOKEN" "$IMAGE_URL" << 'PYEOF'
import urllib.request, urllib.parse, json, sys

api, user_id, media_type, caption, token, image_url = sys.argv[1:]

params = {
    "media_type": media_type,
    "text": caption,
    "access_token": token,
}
if image_url:
    params["image_url"] = image_url

encoded = urllib.parse.urlencode(params).encode()
req = urllib.request.Request(f"{api}/{user_id}/threads", data=encoded, method="POST")
try:
    resp = urllib.request.urlopen(req)
    print(resp.read().decode())
except Exception as e:
    if hasattr(e, 'read'):
        print(e.read().decode())
    else:
        print(json.dumps({"error": str(e)}))
PYEOF
)

CONTAINER_ID=$(echo "$CONTAINER_RESP" | python3 -c \
  "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[[ -n "$CONTAINER_ID" ]] || err "컨테이너 생성 실패: $CONTAINER_RESP"
log "✅ 컨테이너: $CONTAINER_ID"

# ── Step 2: 게시 ──────────────────────────────────────────
sleep 2
log "🚀 Threads 게시 중..."

PUBLISH_RESP=$(python3 - "$API_BASE" "$USER_ID" "$CONTAINER_ID" "$TOKEN" << 'PYEOF'
import urllib.request, urllib.parse, json, sys

api, user_id, container_id, [REDACTED_SECRET]
params = urllib.parse.urlencode({
    "creation_id": container_id,
    "access_token": token
}).encode()
req = urllib.request.Request(f"{api}/{user_id}/threads_publish", data=params, method="POST")
try:
    resp = urllib.request.urlopen(req)
    print(resp.read().decode())
except Exception as e:
    if hasattr(e, 'read'):
        print(e.read().decode())
    else:
        print(json.dumps({"error": str(e)}))
PYEOF
)

THREAD_ID=$(echo "$PUBLISH_RESP" | python3 -c \
  "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[[ -n "$THREAD_ID" ]] || err "게시 실패: $PUBLISH_RESP"

log "✅ Threads 게시 완료! thread_id=$THREAD_ID"
echo "$THREAD_ID"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)