응 진행해주고, 코드랑 짜서 올려줘









좋아, 그럼 “카페24 기본 FTP(d.cafe24.com) + Admin API + 구글만으로 자동화” 기준으로

바로 v0.1.0 실전 코드/구조까지 한 번에 올려줄게.

(너는 Sheets만 채우고, Drive에 원본 넣으면, 배치가 자동으로 올리는 그림)

아래는 바로 Cloud Run에 올려서 쓰는 형태로 짰어.


v0.1.0 전체 구조(파일 트리)

graphql
cafe24_publisher_v0.1.0/ ├─ app/ │ ├─ main.py # Cloud Run FastAPI 엔트리 │ ├─ cafe24_client.py # 카페24 Admin API 클라이언트(+토큰 갱신) │ ├─ ftp_uploader.py # 카페24 기본 FTP 업로더 │ ├─ sheets_client.py # Google Sheets 읽기/쓰기 │ ├─ detail_builder.py # 상세 HTML 템플릿 머지 │ ├─ config.py # 환경변수 │ └─ utils.py ├─ requirements.txt ├─ Dockerfile └─ README.md

0) 사전 준비(딱 1번만)

A. 카페24 개발자센터에서 앱 생성 & OAuth

처음 1번만 사람이 하는 일

  1. 카페24 개발자센터 → App 생성(Web)

  2. Redirect URI 지정(예: https://YOUR_RUN_URL/oauth/callback)

  3. 권한(scope)에 mall.read_product, mall.write_product 등 상품 관련 scope 포함

  4. 브라우저로 인증 → authorization code 발급

  5. /oauth/callback이 code 받아서 토큰 발급 & 저장

이 이후부터는 refresh token으로 자동 갱신되니까

사람이 다시 할 일 거의 없음(14일 안에서 계속 갱신되도록 배치가 돌기 때문).

B. 카페24 기본 FTP 계정 준비

  • 관리자 → 디자인 → 웹FTP/디자인FTP에서 계정 확인

  • 이미지 업로드 URL 규칙:

    /web/upload/...https://{mallid}.cafe24.com/web/upload/... 카페24 고객센터+1

C. 구글 쪽

  • Sheets 1개(상품 마스터)

  • GCP 프로젝트

  • Cloud Run + Cloud Scheduler

  • Secret Manager(토큰/FTP 비번 저장)


1) Google Sheets 컬럼(최소 MVP)

컬럼명 설명
product_code 내부 상품코드(폴더/파일 규칙의 기준)
product_name 상품명
category_no 카테고리 번호
price 판매가
supply_price 원가(선택)
stock 재고(선택)
main_image_drive_path 드라이브 파일 경로(또는 파일ID)
add_images_drive_paths 추가이미지 경로들(쉼표 구분)
detail_template_id 상세 템플릿 ID
detail_extra_json 상세에 넣을 추가데이터(JSON 문자열)
status draft / uploaded / failed
cafe24_product_no 업로드 후 자동 기입
last_error 실패 사유

2) 코드

app/config.py

python
import os MALL_ID = os.getenv("CAFE24_MALL_ID") CAFE24_CLIENT_ID = os.getenv("CAFE24_CLIENT_ID") CAFE24_CLIENT_SECRET = os.getenv("CAFE24_CLIENT_SECRET") CAFE24_REDIRECT_URI = os.getenv("CAFE24_REDIRECT_URI") # 토큰/리프레시토큰 저장 위치(Secret Manager 쓰는 전제) GCP_PROJECT = os.getenv("GCP_PROJECT") TOKEN_SECRET_NAME = os.getenv("TOKEN_SECRET_NAME", "cafe24-token-json") # FTP FTP_HOST = os.getenv("CAFE24_FTP_HOST") # 예: {mallid}.ftp.cafe24.com FTP_USER = os.getenv("CAFE24_FTP_USER") FTP_PASS = os.getenv("CAFE24_FTP_PASS") FTP_BASE_DIR = os.getenv("CAFE24_FTP_BASE_DIR", "/web/upload") # Sheets SHEET_ID = os.getenv("SHEET_ID") SHEET_NAME = os.getenv("SHEET_NAME", "products") # Drive 다운로드 방식에 따라 service account 권한 필요 DRIVE_ROOT_FOLDER = os.getenv("DRIVE_ROOT_FOLDER", "")

app/cafe24_client.py

python
import json, time, requests from .config import * from .utils import get_secret_json, put_secret_json API_BASE = f"https://{MALL_ID}.cafe24api.com/api/v2/admin" class Cafe24Client: def __init__(self): self.session = requests.Session() def _load_tokens(self): data = get_secret_json(GCP_PROJECT, TOKEN_SECRET_NAME) or {} return data.get("access_token"), data.get("refresh_token"), data.get("expires_at", 0) def _save_tokens(self, access_token, refresh_token, expires_in): payload = { "access_token": access_token, "refresh_token": refresh_token, "expires_at": int(time.time()) + int(expires_in) - 60 } put_secret_json(GCP_PROJECT, TOKEN_SECRET_NAME, payload) def _refresh_if_needed(self): access_token, refresh_token, expires_at = self._load_tokens() if access_token and time.time() < expires_at: return access_token if not refresh_token: raise RuntimeError("No refresh token. Do OAuth once via /oauth/start") url = "https://oauth.cafe24.com/api/v2/oauth/token" data = { "grant_type": "refresh_token", "refresh_token": refresh_token } auth = (CAFE24_CLIENT_ID, CAFE24_CLIENT_SECRET) r = self.session.post(url, data=data, auth=auth, timeout=30) r.raise_for_status() j = r.json() self._save_tokens(j["access_token"], j.get("refresh_token", refresh_token), j["expires_in"]) return j["access_token"] def _headers(self): token = self._refresh_if_needed() return { "Authorization": f"Bearer {token}", "Content-Type": "application/json", "X-Cafe24-Api-Version": "2024-06-01" # 운영하면서 고정 권장 } def create_product(self, payload: dict) -> int: r = self.session.post(f"{API_BASE}/products", headers=self._headers(), json=payload, timeout=60) r.raise_for_status() return r.json()["product"]["product_no"] def update_product(self, product_no: int, payload: dict): r = self.session.put(f"{API_BASE}/products/{product_no}", headers=self._headers(), json=payload, timeout=60) r.raise_for_status() def add_additional_images(self, product_no: int, image_urls: list[str]): # additionalimages 최대 20장 고려. :contentReference[oaicite:3]{index=3} items = [{"image_url": u} for u in image_urls] r = self.session.post( f"{API_BASE}/products/{product_no}/additionalimages", headers=self._headers(), json={"additionalimages": items}, timeout=60 ) r.raise_for_status()

app/ftp_uploader.py

python
import os, ftplib, posixpath from .config import * class FtpUploader: def __init__(self): self.ftp = ftplib.FTP(FTP_HOST, FTP_USER, FTP_PASS, timeout=30) self.ftp.encoding = "utf-8" def _ensure_dir(self, remote_dir): # remote_dir: /web/upload/AAA001 parts = remote_dir.strip("/").split("/") cur = "" for p in parts: cur += "/" + p try: self.ftp.mkd(cur) except ftplib.error_perm: pass def upload_file(self, local_path: str, product_code: str) -> str: filename = os.path.basename(local_path) remote_dir = posixpath.join(FTP_BASE_DIR, product_code) self._ensure_dir(remote_dir) self.ftp.cwd(remote_dir) with open(local_path, "rb") as f: self.ftp.storbinary(f"STOR {filename}", f) # 업로드 후 접근 URL 만들기 :contentReference[oaicite:4]{index=4} return f"https://{MALL_ID}.cafe24.com{remote_dir}/{filename}" def close(self): try: self.ftp.quit() except: pass

app/sheets_client.py

python
import pandas as pd import gspread from google.oauth2.service_account import Credentials from .config import * SCOPES = [ "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive.readonly", ] def get_gspread_client(): creds = Credentials.from_service_account_file( "/secrets/service-account.json", scopes=SCOPES ) return gspread.authorize(creds) def load_products_df(): gc = get_gspread_client() sh = gc.open_by_key(SHEET_ID) ws = sh.worksheet(SHEET_NAME) rows = ws.get_all_records() return pd.DataFrame(rows), ws def update_row(ws, row_idx: int, updates: dict): # row_idx는 2부터 시작(헤더 제외) headers = ws.row_values(1) for k, v in updates.items(): col = headers.index(k) + 1 ws.update_cell(row_idx, col, v)

app/detail_builder.py

python
import json from string import Template # 템플릿은 Drive나 로컬에 두고 읽는다고 가정. # v0.1은 로컬 templates/{id}.html 로 처리. def build_detail_html(template_str: str, data: dict) -> str: # data는 Sheets의 detail_extra_json + 기본 필드 t = Template(template_str) return t.safe_substitute(**data)

app/main.py

python
import os, json, traceback from fastapi import FastAPI, Query from fastapi.responses import RedirectResponse from .cafe24_client import Cafe24Client from .ftp_uploader import FtpUploader from .sheets_client import load_products_df, update_row from .detail_builder import build_detail_html from .config import * from .utils import download_drive_file, put_secret_json app = FastAPI() # ---------- OAuth 1회용 시작/콜백 ---------- @app.get("/oauth/start") def oauth_start(): scope = "mall.read_product mall.write_product" url = ( "https://oauth.cafe24.com/api/v2/oauth/authorize" f"?response_type=code&client_id={CAFE24_CLIENT_ID}" f"&redirect_uri={CAFE24_REDIRECT_URI}" f"&scope={scope}&state=init" ) return RedirectResponse(url) @app.get("/oauth/callback") def oauth_callback(code: str = Query(...)): import requests, time url = "https://oauth.cafe24.com/api/v2/oauth/token" data = { "grant_type": "authorization_code", "code": code, "redirect_uri": CAFE24_REDIRECT_URI } r = requests.post(url, data=data, auth=(CAFE24_CLIENT_ID, CAFE24_CLIENT_SECRET), timeout=30) r.raise_for_status() j = r.json() put_secret_json(GCP_PROJECT, TOKEN_SECRET_NAME, { "access_token": j["access_token"], "refresh_token": j["refresh_token"], "expires_at": int(time.time()) + int(j["expires_in"]) - 60 }) return {"ok": True, "message": "tokens saved"} # ---------- 메인 배치 ---------- @app.post("/run-batch") def run_batch(limit: int = 50): df, ws = load_products_df() targets = df[df["status"].isin(["draft", "", None])].head(limit) cafe = Cafe24Client() ftp = FtpUploader() processed = 0 for idx, row in targets.iterrows(): row_idx = idx + 2 # 헤더 제외 try: product_code = row["product_code"] # 1) Drive -> 로컬 다운 (v0.1: utils에서 구현) main_local = download_drive_file(row["main_image_drive_path"], f"/tmp/{product_code}_main.jpg") add_paths = [p.strip() for p in str(row.get("add_images_drive_paths","")).split(",") if p.strip()] add_locals = [] for i, p in enumerate(add_paths): add_locals.append(download_drive_file(p, f"/tmp/{product_code}_add_{i}.jpg")) # 2) FTP 업로드 -> 카페24 URL main_url = ftp.upload_file(main_local, product_code) add_urls = [ftp.upload_file(lp, product_code) for lp in add_locals] # 3) 상세 HTML 생성(템플릿 로컬 파일 가정) template_id = row.get("detail_template_id", "default") with open(f"templates/{template_id}.html", "r", encoding="utf-8") as f: template_str = f.read() extra = {} if row.get("detail_extra_json"): extra = json.loads(row["detail_extra_json"]) detail_html = build_detail_html(template_str, { "product_name": row["product_name"], "main_image_url": main_url, **extra }) # 4) 상품 payload 구성 payload = { "product": { "product_name": row["product_name"], "category_no": int(row["category_no"]), "price": float(row["price"]), "supply_price": float(row.get("supply_price") or 0), "stock": int(row.get("stock") or 0), "detail_description": detail_html, "image_upload_type": "A", "main": { "image_url": main_url } } } # 5) 생성/수정 분기 product_no = row.get("cafe24_product_no") if not product_no: product_no = cafe.create_product(payload) else: cafe.update_product(int(product_no), payload) # 6) 추가 이미지 연결 if add_urls: cafe.add_additional_images(int(product_no), add_urls[:20]) # 7) Sheets 업데이트 update_row(ws, row_idx, { "status": "uploaded", "cafe24_product_no": str(product_no), "last_error": "" }) processed += 1 except Exception as e: update_row(ws, row_idx, { "status": "failed", "last_error": f"{e}" }) ftp.close() return {"processed": processed}

app/utils.py

python
import json, base64, os from google.cloud import secretmanager from googleapiclient.discovery import build from google.oauth2.service_account import Credentials from .config import * def get_secret_json(project_id, name): client = secretmanager.SecretManagerServiceClient() secret_name = f"projects/{project_id}/secrets/{name}/versions/latest" try: resp = client.access_secret_version(name=secret_name) return json.loads(resp.payload.data.decode("utf-8")) except Exception: return None def put_secret_json(project_id, name, payload: dict): client = secretmanager.SecretManagerServiceClient() parent = f"projects/{project_id}/secrets/{name}" client.add_secret_version( parent=parent, payload={"data": json.dumps(payload).encode("utf-8")} ) def download_drive_file(drive_path_or_id: str, out_path: str): # v0.1: drive_path_or_id가 "FILE_ID" 라고 가정. creds = Credentials.from_service_account_file( "/secrets/service-account.json", scopes=["https://www.googleapis.com/auth/drive.readonly"] ) service = build("drive", "v3", credentials=creds) request = service.files().get_media(fileId=drive_path_or_id) fh = open(out_path, "wb") downloader = __import__("googleapiclient.http").http.MediaIoBaseDownload(fh, request) done = False while not done: status, done = downloader.next_chunk() fh.close() return out_path

requirements.txt

ini
fastapi==0.115.0 uvicorn==0.30.6 requests==2.32.3 gspread==6.1.2 pandas==2.2.3 google-api-python-client==2.149.0 google-auth==2.35.0 google-cloud-secret-manager==2.21.1 python-multipart==0.0.9

Dockerfile

dockerfile
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app COPY templates ./templates ENV PORT=8080 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

3) Cloud Scheduler로 배치 돌리기

  • 매일 밤 11시 같은 시간에 POST /run-batch 호출

  • limit으로 한 번에 50개씩 올리고, 더 많으면 스케줄러 2~3번 연속 호출


4) 지금 버전에서 너가 당장 할 일

  1. Sheets 컬럼 맞춰서 상품 데이터 채우기

  2. Drive 파일을 FILE_ID로 넣기(경로 대신 ID)

  3. GCP 서비스계정 JSON을 Secret로 넣고 /secrets/service-account.json 마운트

  4. Cloud Run 배포

  5. /oauth/start 한 번 실행해서 토큰 저장

  6. /run-batch 호출 → 상품 자동 등록 시작


다음 단계(v0.2.0에서 바로 할 개선)

  • Drive “경로”를 넣어도 찾아 다운로드하도록 개선

  • 이미지 리사이즈/워터마크/파일명 규칙 자동화

  • 옵션/세트/중량 JSON 자동 변환 로직

  • 실패 재처리 버튼(Apps Script UI)


원하면 네 실제 Sheets 샘플(컬럼/옵션 방식) 기준으로

payload 구성(옵션/세트/배송비/과세/원산지)을 v0.2.0에 맞춰 정교하게 바꿔줄게.

지금 쓰는 상품시트 구조만 한 번 캡쳐/텍스트로 던져줘.

댓글 남기기