# 바디코디 + 온스튜디오 통합 자동화 — 1단계 구현 계획

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. 체크박스 (`- [ ]`) 로 진척 추적.

**Goal:** 매일 오전 8시에 두 시스템(바디코디·온스튜디오)의 오늘 수업·예약 현황을 통합한 메일을 발송하고, 온스튜디오 신청자를 바디코디에 자동 예약 등록한다.

**Architecture:** Python + Playwright로 두 관리자 페이지에 로그인·스크래핑·예약 등록을 수행. 표준 SMTP로 네이버 메일 발송. macOS launchd로 매일 자동 실행. 자격증명은 `.env`에 분리, `state.json`으로 중복 방지.

**Tech Stack:** Python 3.11+, Playwright (Chromium), python-dotenv, smtplib, macOS launchd

**스펙 문서:** `/Users/midbar_/Desktop/Claude/2026-05-04-bodycody-onstudio-spec.html`

---

## 사용자 vs Claude 작업 구분

- 🧑 **사용자** 표시 = 직접 해주셔야 하는 일 (자격증명 발급, 화면 확인 등)
- 🤖 **Claude** 표시 = 제가 자동으로 처리
- 👀 **검증** 표시 = 함께 결과를 확인하는 단계

---

## File Structure

```
bodycody-onstudio/
├── .env                  자격증명 (gitignore)
├── .env.example          템플릿
├── .gitignore
├── requirements.txt      의존성
├── README.md             사용법
├── src/
│   ├── __init__.py
│   ├── config.py         환경변수 로딩, 상수
│   ├── models.py         dataclass: ClassSession, Attendee, SyncResult
│   ├── bodycody_client.py 바디코디 어댑터 (로그인·조회·예약등록)
│   ├── onstudio_client.py 온스튜디오 어댑터 (로그인·조회)
│   ├── sync.py           온스튜디오→바디코디 동기화
│   ├── report.py         통합 데이터 → HTML 메일 본문
│   ├── mailer.py         네이버 SMTP 발송
│   └── main.py           오케스트레이션
├── tests/
│   ├── test_report.py    메일 본문 단위 테스트
│   ├── test_sync.py      동기화 로직 단위 테스트
│   └── fixtures/         스크래핑 결과 샘플 데이터
├── data/
│   └── state.json        이미 등록한 예약 추적 (자동 생성)
├── logs/
│   └── run-YYYY-MM-DD.log 실행 로그 (자동 생성)
└── launchd/
    └── com.midbar.bodycody-onstudio.plist  스케줄 정의
```

---

## Phase 1 — 환경 셋업

### Task 1.1: Python·Playwright 설치 확인

**Files:** 없음 (사전 점검)

- [ ] **Step 1 🤖:** Python 버전 확인
  ```bash
  python3 --version
  ```
  Expected: `Python 3.11.x` 이상. 미설치 시 `brew install python@3.11`

- [ ] **Step 2 🤖:** 작업 디렉토리 이동
  ```bash
  cd /Users/midbar_/Desktop/Claude/bodycody-onstudio
  ```

### Task 1.2: 가상환경 + 의존성 설치

**Files:**
- Create: `bodycody-onstudio/requirements.txt`
- Create: `bodycody-onstudio/.gitignore`

- [ ] **Step 1 🤖:** `requirements.txt` 작성
  ```
  playwright==1.50.0
  python-dotenv==1.0.1
  pytest==8.3.4
  ```

- [ ] **Step 2 🤖:** `.gitignore` 작성
  ```
  .env
  __pycache__/
  *.pyc
  .pytest_cache/
  data/state.json
  logs/
  .venv/
  ```

- [ ] **Step 3 🤖:** 가상환경 생성 + 의존성 설치
  ```bash
  python3 -m venv .venv
  source .venv/bin/activate
  pip install -r requirements.txt
  playwright install chromium
  ```
  Expected: chromium 다운로드 (~150MB) 완료

- [ ] **Step 4 👀:** 설치 검증
  ```bash
  python3 -c "from playwright.sync_api import sync_playwright; print('OK')"
  ```
  Expected: `OK` 출력

### Task 1.3: 자격증명 입력 (사용자 액션)

**Files:**
- Create: `bodycody-onstudio/.env.example`
- Create: `bodycody-onstudio/.env`

- [ ] **Step 1 🤖:** `.env.example` 템플릿 생성
  ```
  BODYCODI_ID=
  BODYCODI_PW=
  ONSTUDIO_ID=
  ONSTUDIO_PW=
  NAVER_MAIL_ADDR=midbar_@naver.com
  NAVER_APP_PW=
  REPORT_TO=midbar_@naver.com
  DRY_RUN=true
  ```

- [ ] **Step 2 🧑:** 네이버 앱 비밀번호 발급
  - 네이버 로그인 → 환경설정 → 메일 → POP3/IMAP 설정
  - "IMAP/SMTP 사용" ON → "앱 비밀번호" 발급 → 16자리 복사

- [ ] **Step 3 🧑:** `.env` 파일에 자격증명 입력
  - `cp .env.example .env` 후 텍스트 편집기로 열어 4개 값 채우기
  - 바디코디 ID/PW, 온스튜디오 ID/PW, 네이버 앱 비밀번호

- [ ] **Step 4 🤖:** `config.py` 작성 — 환경변수 로딩
  ```python
  # src/config.py
  from dotenv import load_dotenv
  import os
  from pathlib import Path

  PROJECT_ROOT = Path(__file__).parent.parent
  load_dotenv(PROJECT_ROOT / ".env")

  BODYCODI_ID = os.environ["BODYCODI_ID"]
  BODYCODI_PW = os.environ["BODYCODI_PW"]
  ONSTUDIO_ID = os.environ["ONSTUDIO_ID"]
  ONSTUDIO_PW = os.environ["ONSTUDIO_PW"]
  NAVER_MAIL_ADDR = os.environ["NAVER_MAIL_ADDR"]
  NAVER_APP_PW = os.environ["NAVER_APP_PW"]
  REPORT_TO = os.environ.get("REPORT_TO", NAVER_MAIL_ADDR)
  DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

  STATE_FILE = PROJECT_ROOT / "data" / "state.json"
  LOG_DIR = PROJECT_ROOT / "logs"
  ```

- [ ] **Step 5 👀:** 자격증명 로딩 검증
  ```bash
  python3 -c "from src import config; print('BODYCODI_ID:', bool(config.BODYCODI_ID))"
  ```
  Expected: `BODYCODI_ID: True`

---

## Phase 2 — 바디코디 로그인 자동화

### Task 2.1: 로그인 페이지 구조 확인 (함께)

**Files:** 없음 (탐색 단계)

- [ ] **Step 1 🧑:** 바디코디 로그인 페이지 URL 확인 후 공유
  - `https://crm.bodycodi.com/manager` 또는 별도 로그인 URL?

- [ ] **Step 2 🤖:** Playwright로 비headless 모드 실행해 로그인 페이지 진입
  ```python
  # 임시 탐색 스크립트
  from playwright.sync_api import sync_playwright
  with sync_playwright() as p:
      browser = p.chromium.launch(headless=False)
      page = browser.new_page()
      page.goto("LOGIN_URL")
      input("로그인 후 Enter")
      print(page.url)
  ```

- [ ] **Step 3 👀:** 함께 화면 보면서 로그인 폼 셀렉터 식별
  - DevTools(F12)로 ID·비밀번호 input의 `name`/`id` 확인
  - 로그인 후 리다이렉트 URL 확인

### Task 2.2: BodycodiClient 클래스 골격

**Files:**
- Create: `src/bodycody_client.py`

- [ ] **Step 1 🤖:** 로그인 메서드 구현 (실제 셀렉터는 Task 2.1 결과 반영)
  ```python
  # src/bodycody_client.py
  from playwright.sync_api import sync_playwright, Page, Browser
  from src import config

  LOGIN_URL = "https://crm.bodycodi.com/login"  # Task 2.1에서 확정
  SCHEDULE_URL = "https://crm.bodycodi.com/manager/schedule/class"

  class BodycodiClient:
      def __init__(self, headless: bool = True):
          self._pw = sync_playwright().start()
          self.browser: Browser = self._pw.chromium.launch(headless=headless)
          self.page: Page = self.browser.new_page()

      def login(self):
          self.page.goto(LOGIN_URL)
          self.page.fill('input[name="userId"]', config.BODYCODI_ID)
          self.page.fill('input[name="password"]', config.BODYCODI_PW)
          self.page.click('button[type="submit"]')
          self.page.wait_for_url(lambda u: "/manager" in u, timeout=10000)

      def close(self):
          self.browser.close()
          self._pw.stop()
  ```

- [ ] **Step 2 👀:** 헤드 모드로 로그인 검증
  ```bash
  python3 -c "from src.bodycody_client import BodycodiClient; c = BodycodiClient(headless=False); c.login(); input('확인 후 Enter'); c.close()"
  ```
  Expected: 브라우저가 열리고 자동 로그인됨

- [ ] **Step 3 🤖:** Commit
  ```bash
  git init && git add . && git commit -m "feat: 바디코디 로그인 자동화 추가"
  ```

---

## Phase 3 — 온스튜디오 로그인 자동화

### Task 3.1: 온스튜디오 페이지 구조 확인

**Files:** 없음

- [ ] **Step 1 🧑:** 온스튜디오 관리자 로그인 URL 공유
  - `https://onstudio.kr/place/habitstudio/calendar`는 캘린더 페이지일 가능성 — 관리자 로그인 진입 경로 확인 필요

- [ ] **Step 2 👀:** Task 2.1과 동일 방식으로 함께 셀렉터 식별

### Task 3.2: OnstudioClient 클래스 골격

**Files:**
- Create: `src/onstudio_client.py`

- [ ] **Step 1 🤖:** 구조는 BodycodiClient와 동일. 셀렉터·URL만 교체
- [ ] **Step 2 👀:** 헤드 모드 로그인 검증
- [ ] **Step 3 🤖:** Commit

---

## Phase 4 — 데이터 모델 + 스크래핑

### Task 4.1: 데이터 모델 정의

**Files:**
- Create: `src/models.py`

- [ ] **Step 1 🤖:** dataclass 정의
  ```python
  # src/models.py
  from dataclasses import dataclass, field
  from typing import Literal

  Source = Literal["bodycodi", "onstudio"]

  @dataclass
  class Attendee:
      name: str  # "홍길동" 또는 "오붓1"
      source: Source

  @dataclass
  class ClassSession:
      time: str          # "07:00"
      title: str         # "모닝요가"
      instructor: str    # "김XX"
      capacity: int      # 8
      bodycodi_attendees: list[Attendee] = field(default_factory=list)
      onstudio_attendees: list[Attendee] = field(default_factory=list)

      @property
      def total_count(self) -> int:
          return len(self.bodycodi_attendees) + len(self.onstudio_attendees)

  @dataclass
  class SyncResult:
      session_time: str
      session_title: str
      attendee_name: str
      success: bool
      reason: str = ""  # 실패 사유
  ```

### Task 4.2: 바디코디 오늘 수업 조회

**Files:**
- Modify: `src/bodycody_client.py`

- [ ] **Step 1 🧑:** 함께 스케줄 페이지에서 오늘 수업 카드의 HTML 구조 확인
- [ ] **Step 2 🤖:** `fetch_today_classes()` 메서드 추가 — 시간/제목/강사/정원/예약자 명단 추출
- [ ] **Step 3 👀:** 헤드 모드로 결과 출력 검증
  ```python
  classes = client.fetch_today_classes()
  for c in classes:
      print(c.time, c.title, c.bodycodi_attendees)
  ```

### Task 4.3: 온스튜디오 오늘 수업 조회

**Files:**
- Modify: `src/onstudio_client.py`

- [ ] **Step 1-3:** Task 4.2와 동일 방식

### Task 4.4: 통합 데이터 정리 함수

**Files:**
- Create: `src/sync.py` (먼저 merge 함수만)

- [ ] **Step 1 🤖:** 같은 시간·제목 수업을 묶어 통합
  ```python
  def merge_sessions(bodycodi: list[ClassSession], onstudio: list[ClassSession]) -> list[ClassSession]:
      """시간+제목으로 매칭. 양쪽에 같은 수업이 있으면 attendees를 합치고, 한쪽만 있으면 그대로 추가."""
      # 구현
  ```
- [ ] **Step 2 🤖:** 단위 테스트 (TDD: 먼저 실패하는 테스트 작성)
- [ ] **Step 3 🤖:** Commit

---

## Phase 5 — 메일 본문 + 발송

### Task 5.1: HTML 메일 본문 생성기 (TDD)

**Files:**
- Create: `tests/test_report.py`
- Create: `src/report.py`

- [ ] **Step 1 🤖:** 실패 테스트 작성
  ```python
  # tests/test_report.py
  from src.report import build_email_html
  from src.models import ClassSession, Attendee

  def test_빈_수업이면_빈_상태_메시지():
      html = build_email_html([], [], date_str="2026-05-04")
      assert "오늘 수업 없음" in html

  def test_시간순_정렬():
      sessions = [
          ClassSession("09:00", "필라테스", "이ZZ", 4),
          ClassSession("07:00", "모닝요가", "김XX", 8,
              bodycodi_attendees=[Attendee("이경수", "bodycodi")],
              onstudio_attendees=[Attendee("이정", "onstudio")]),
      ]
      html = build_email_html(sessions, [], date_str="2026-05-04")
      assert html.index("07:00") < html.index("09:00")
      assert "총 2명" in html
  ```

- [ ] **Step 2 🤖:** 실행 → 실패 확인
  ```bash
  pytest tests/test_report.py -v
  ```
  Expected: ImportError or AssertionError

- [ ] **Step 3 🤖:** 최소 구현 — `build_email_html` 함수 작성
  - 시간순 정렬 → 시간대별 박스 → 바디코디·온스튜디오 라인 → 총 N명 + 동기화 결과
  - 출력 형식은 스펙의 메일 본문 예시 그대로

- [ ] **Step 4 🤖:** 테스트 통과 확인
  ```bash
  pytest tests/test_report.py -v
  ```
  Expected: 2 passed

- [ ] **Step 5 🤖:** Commit `feat: HTML 메일 본문 생성기 + 테스트`

### Task 5.2: 네이버 SMTP 발송

**Files:**
- Create: `src/mailer.py`

- [ ] **Step 1 🤖:** smtplib 기반 발송 함수
  ```python
  # src/mailer.py
  import smtplib
  from email.mime.text import MIMEText
  from email.mime.multipart import MIMEMultipart
  from src import config

  def send_mail(subject: str, html_body: str, to: str = None):
      to = to or config.REPORT_TO
      msg = MIMEMultipart("alternative")
      msg["Subject"] = subject
      msg["From"] = config.NAVER_MAIL_ADDR
      msg["To"] = to
      msg.attach(MIMEText(html_body, "html", "utf-8"))

      with smtplib.SMTP_SSL("smtp.naver.com", 465, timeout=20) as s:
          s.login(config.NAVER_MAIL_ADDR, config.NAVER_APP_PW)
          s.send_message(msg)
  ```

- [ ] **Step 2 👀:** 테스트 메일 발송
  ```bash
  python3 -c "from src.mailer import send_mail; send_mail('[테스트] 자동화 연결 확인', '<h1>안녕하세요</h1><p>SMTP 연결 OK</p>')"
  ```
  Expected: midbar_@naver.com 수신함에 메일 도착

- [ ] **Step 3 🧑:** 메일 수신 확인
- [ ] **Step 4 🤖:** Commit `feat: 네이버 SMTP 메일 발송`

---

## Phase 6 — 동기화 (온스튜디오 → 바디코디)

### Task 6.1: state.json 추적

**Files:**
- Create: `src/sync.py` (확장)

- [ ] **Step 1 🤖:** state.json 로드/저장 함수
  ```python
  def load_state() -> dict:
      if not config.STATE_FILE.exists():
          return {}
      return json.loads(config.STATE_FILE.read_text())

  def save_state(state: dict):
      config.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
      config.STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))

  def already_synced(state: dict, date: str, session_time: str, name: str) -> bool:
      key = f"{date}|{session_time}|{name}"
      return key in state.get("synced", {})

  def mark_synced(state: dict, date: str, session_time: str, name: str):
      state.setdefault("synced", {})[f"{date}|{session_time}|{name}"] = True
  ```

- [ ] **Step 2 🤖:** 단위 테스트 — synced 추적 검증

### Task 6.2: 바디코디 예약 등록 메서드

**Files:**
- Modify: `src/bodycody_client.py`

- [ ] **Step 1 🧑:** 예약 등록 화면 UI 함께 확인 (어떤 버튼·필드를 거치면 등록되는지)
- [ ] **Step 2 🤖:** `add_reservation(session_time, attendee_name)` 메서드 구현
  - 성공: True 반환
  - 실패: SyncResult 사유 명시 ("정원 초과", "수업 매칭 실패" 등)

- [ ] **Step 3 👀:** **반드시 dry-run / 헤드 모드**로 검증. 실등록 전 단계까지만 진행.

### Task 6.3: 동기화 오케스트레이션

**Files:**
- Modify: `src/sync.py`

- [ ] **Step 1 🤖:** `run_sync()` 함수 — 통합 세션 순회하며 온스튜디오 신청자 중 미등록자만 바디코디에 추가, SyncResult 리스트 반환
- [ ] **Step 2 🤖:** `DRY_RUN=true`이면 실제 등록 안 하고 시뮬레이션만
- [ ] **Step 3 🤖:** 단위 테스트 (mock 클라이언트로)
- [ ] **Step 4 🤖:** Commit

---

## Phase 7 — main.py 오케스트레이션 + 실행 검증

### Task 7.1: main.py 작성

**Files:**
- Create: `src/main.py`

- [ ] **Step 1 🤖:** 전체 파이프라인 실행 + 로그 파일 작성
  ```python
  # src/main.py
  from datetime import date
  import logging
  from src import config
  from src.bodycody_client import BodycodiClient
  from src.onstudio_client import OnstudioClient
  from src.sync import merge_sessions, run_sync, load_state, save_state
  from src.report import build_email_html
  from src.mailer import send_mail

  def main():
      today = date.today().isoformat()
      # 로그 셋업
      config.LOG_DIR.mkdir(parents=True, exist_ok=True)
      logging.basicConfig(
          filename=config.LOG_DIR / f"run-{today}.log",
          level=logging.INFO,
          format="%(asctime)s [%(levelname)s] %(message)s",
      )
      log = logging.getLogger(__name__)
      log.info("=== 시작 ===")

      bodycodi = BodycodiClient()
      onstudio = OnstudioClient()
      try:
          bodycodi.login()
          onstudio.login()

          bc_sessions = bodycodi.fetch_today_classes()
          os_sessions = onstudio.fetch_today_classes()
          merged = merge_sessions(bc_sessions, os_sessions)

          state = load_state()
          sync_results = run_sync(merged, bodycodi, state, dry_run=config.DRY_RUN)
          save_state(state)

          subject = f"[수업알림] {today} 오늘의 수업 · 총 {sum(s.total_count for s in merged)}명"
          html = build_email_html(merged, sync_results, date_str=today, dry_run=config.DRY_RUN)
          send_mail(subject, html)
          log.info("=== 완료 ===")
      except Exception as e:
          log.exception("실행 중 에러")
          send_mail(f"[자동화 에러] {today}", f"<pre>{e}</pre>")
          raise
      finally:
          bodycodi.close()
          onstudio.close()

  if __name__ == "__main__":
      main()
  ```

- [ ] **Step 2 👀:** 수동 실행으로 풀 파이프라인 검증 (DRY_RUN=true)
  ```bash
  source .venv/bin/activate
  python3 -m src.main
  ```
  Expected: 메일 수신, 로그 파일 생성

- [ ] **Step 3 🧑:** 메일 본문이 스펙 형식과 일치하는지 확인

- [ ] **Step 4 🤖:** Commit

---

## Phase 8 — launchd 자동 스케줄

### Task 8.1: plist 작성

**Files:**
- Create: `launchd/com.midbar.bodycody-onstudio.plist`

- [ ] **Step 1 🤖:** plist 작성 (매일 08:00 실행)
  ```xml
  <?xml version="1.0" encoding="UTF-8"?>
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
  <plist version="1.0">
  <dict>
    <key>Label</key>
    <string>com.midbar.bodycody-onstudio</string>
    <key>ProgramArguments</key>
    <array>
      <string>/Users/midbar_/Desktop/Claude/bodycody-onstudio/.venv/bin/python3</string>
      <string>-m</string>
      <string>src.main</string>
    </array>
    <key>WorkingDirectory</key>
    <string>/Users/midbar_/Desktop/Claude/bodycody-onstudio</string>
    <key>StartCalendarInterval</key>
    <dict>
      <key>Hour</key>
      <integer>8</integer>
      <key>Minute</key>
      <integer>0</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>/Users/midbar_/Desktop/Claude/bodycody-onstudio/logs/launchd-out.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/midbar_/Desktop/Claude/bodycody-onstudio/logs/launchd-err.log</string>
  </dict>
  </plist>
  ```

- [ ] **Step 2 🤖:** `~/Library/LaunchAgents`에 심볼릭 링크
  ```bash
  ln -sf /Users/midbar_/Desktop/Claude/bodycody-onstudio/launchd/com.midbar.bodycody-onstudio.plist ~/Library/LaunchAgents/
  launchctl load ~/Library/LaunchAgents/com.midbar.bodycody-onstudio.plist
  ```

- [ ] **Step 3 👀:** 즉시 실행 트리거로 검증
  ```bash
  launchctl start com.midbar.bodycody-onstudio
  ```
  Expected: 1~2분 내 메일 수신, `logs/launchd-out.log` 생성

- [ ] **Step 4 🤖:** Commit `feat: launchd 매일 08:00 자동 실행`

---

## Phase 9 — 1주일 dry-run 검증

### Task 9.1: 매일 모니터링

- [ ] **Step 1 🧑:** 매일 아침 메일 도착 여부 확인
- [ ] **Step 2 🧑:** 메일 내용이 실제 수업·예약자와 일치하는지 비교
- [ ] **Step 3 🧑:** 동기화 시뮬레이션 결과(어떤 사람이 등록될 예정인지)가 정확한지 확인
- [ ] **Step 4:** 7일간 누적 점검 — 이상 없으면 Phase 10으로

### Task 9.2: 발견된 문제 수정

- [ ] 문제 발생 시 그때그때 패치 + 커밋. `state.json` 잘못 쌓이면 초기화 가능.

---

## Phase 10 — 실제 등록 활성화

### Task 10.1: DRY_RUN 해제

**Files:**
- Modify: `bodycody-onstudio/.env`

- [ ] **Step 1 🧑:** `.env`에서 `DRY_RUN=false` 변경
- [ ] **Step 2 👀:** 다음날 아침 메일에서 ✅ 등록 성공이 실제로 바디코디에 반영되었는지 사용자가 확인
- [ ] **Step 3:** 1주 더 모니터링 후 안정화 선언

---

## 다음 단계 (별도 스펙 필요)

- **2단계:** 수업 자체의 신규 등록·수정·삭제 자동화
- **3단계:** 회원 만료 임박 알림, 신규 가입 알림
- **확장:** Slack DM 채널 추가, 웹 대시보드

---

## 자기 검토 결과

**Spec coverage:** ✅ 스펙의 모든 항목(통합 알림, 자동 동기화, 에러 처리, dry-run, 안전장치)이 작업으로 매핑됨.
**Placeholder scan:** Task 2.1·3.1·4.2·6.2의 셀렉터·UI는 사용자와 함께 실제 페이지 보면서 채워야 하는 항목이라 의도된 미정.
**Type consistency:** ClassSession·Attendee·SyncResult 타입 일관 사용 확인.
