diff --git a/.github/workflows/PROJECT-PYTHON-SYNOLOGY-PR-PREVIEW.yaml b/.github/workflows/PROJECT-PYTHON-SYNOLOGY-PR-PREVIEW.yaml index 63c26a3..adf43f0 100644 --- a/.github/workflows/PROJECT-PYTHON-SYNOLOGY-PR-PREVIEW.yaml +++ b/.github/workflows/PROJECT-PYTHON-SYNOLOGY-PR-PREVIEW.yaml @@ -33,8 +33,8 @@ # - 서버에 프로젝트 디렉토리 존재 # # ※ Health Check 방식: -# - FastAPI /docs/swagger 엔드포인트 확인 -# - 폴백: 기동 로그 패턴 확인 ("Uvicorn running on") +# - HEALTH_CHECK_PATH로 HTTP 엔드포인트 확인 (기본값: /docs) +# - 폴백: HEALTH_CHECK_LOG_PATTERN으로 로그 패턴 매칭 # # 🌐 Traefik 대시보드: # https://traefik.suhsaechan.kr/dashboard/#/ @@ -80,7 +80,20 @@ env: # Issue Helper 댓글 마커 (프로젝트별 수정 필요) # Issue 댓글에서 브랜치명을 추출할 때 사용 - ISSUE_HELPER_MARKER: 'SUH-ISSUE-HELPER' + ISSUE_HELPER_MARKER: 'Guide by SUH-LAB' + + # Health Check 설정 (프로젝트별 수정 필요) + # - HEALTH_CHECK_PATH: HTTP Health Check 경로 (빈값이면 HTTP 체크 건너뜀) + # - HEALTH_CHECK_LOG_PATTERN: 로그 패턴 매칭 (HTTP 실패 시 폴백) + # - API_DOCS_PATH: API 문서 URL (빈값이면 배포 코멘트에 미표시) + # + # 프레임워크별 기본값 예시: + # FastAPI: '/docs', 'Uvicorn running on|Application startup complete' + # Spring Boot: '/actuator/health', 'Started.*Application|Tomcat started on port' + # Express: '/health', 'Server listening on port' + HEALTH_CHECK_PATH: '/docs' + HEALTH_CHECK_LOG_PATTERN: 'Uvicorn running on|Application startup complete' + API_DOCS_PATH: '/docs' # =================================================================== # 트리거 설정 @@ -351,12 +364,14 @@ jobs: "${IMAGE}" # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - # Health Check (최대 120초 대기) - FastAPI 하이브리드 방식 - # 1. curl로 /docs/swagger 엔드포인트 확인 - # 2. 폴백: 로그 패턴 매칭 ("Uvicorn running on" 또는 "Application startup complete") + # Health Check (최대 120초 대기) - 하이브리드 방식 + # 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도 + # 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ echo "" echo "⏳ Health Check 시작 (최대 120초 대기)..." + HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}" + LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}" MAX_RETRIES=24 RETRY_COUNT=0 HEALTH_CHECK_PASSED=false @@ -380,25 +395,29 @@ jobs: fi if [ "$STATUS" = "running" ]; then - # 2. FastAPI /docs/swagger Health Check 시도 (curl 사용 - Dockerfile에 설치됨) - HEALTH=$(echo $PW | sudo -S docker exec "${CONTAINER_NAME}" curl -sf http://localhost:${INTERNAL_PORT}/docs/swagger 2>/dev/null || echo "") - - if [ -n "$HEALTH" ]; then - echo "✅ FastAPI 정상 기동 확인! (HTTP 응답)" - HEALTH_CHECK_PASSED=true - HEALTH_CHECK_METHOD="HTTP" - break + # 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만) + if [ -n "$HEALTH_PATH" ]; then + HEALTH=$(echo $PW | sudo -S docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "") + + if [ -n "$HEALTH" ]; then + echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="HTTP" + break + fi fi # 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백 - STARTED=$(echo $PW | sudo -S docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "Uvicorn running on|Application startup complete" || echo "") - - if [ -n "$STARTED" ]; then - echo "✅ FastAPI 정상 기동 확인! (로그 패턴)" - echo " $STARTED" - HEALTH_CHECK_PASSED=true - HEALTH_CHECK_METHOD="Log" - break + if [ -n "$LOG_PATTERN" ]; then + STARTED=$(echo $PW | sudo -S docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "") + + if [ -n "$STARTED" ]; then + echo "✅ 정상 기동 확인! (로그 패턴)" + echo " $STARTED" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="Log" + break + fi fi fi @@ -436,11 +455,22 @@ jobs: const projectName = '${{ env.PROJECT_NAME }}'; const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; const previewPort = '${{ env.PREVIEW_PORT }}'; + const apiDocsPath = '${{ env.API_DOCS_PATH }}'; const prNumber = context.issue.number; const domain = `${projectName}-pr-${prNumber}.${domainSuffix}`; const previewUrl = `http://${domain}:${previewPort}`; const sha = '${{ steps.pr.outputs.sha }}'; + // Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가) + const envRows = [ + `| **Preview URL** | ${previewUrl} |`, + ]; + if (apiDocsPath) { + envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`); + } + envRows.push(`| **컨테이너** | \`${projectName}-pr-${prNumber}\` |`); + envRows.push(`| **커밋** | \`${sha}\` |`); + const body = [ '## ✅ PR Preview 배포 완료!', '', @@ -452,10 +482,7 @@ jobs: '### 🌐 Preview 환경', '| 항목 | 값 |', '|------|-----|', - `| **Preview URL** | ${previewUrl} |`, - `| **Swagger Docs** | ${previewUrl}/docs/swagger |`, - `| **컨테이너** | \`${projectName}-pr-${prNumber}\` |`, - `| **커밋** | \`${sha}\` |`, + ...envRows, '', '### 📋 명령어', '| 명령어 | 설명 |', @@ -897,10 +924,14 @@ jobs: "${IMAGE}" # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - # Health Check (최대 120초 대기) - FastAPI 하이브리드 방식 + # Health Check (최대 120초 대기) - 하이브리드 방식 + # 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도 + # 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ echo "" echo "⏳ Health Check 시작 (최대 120초 대기)..." + HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}" + LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}" MAX_RETRIES=24 RETRY_COUNT=0 HEALTH_CHECK_PASSED=false @@ -924,25 +955,29 @@ jobs: fi if [ "$STATUS" = "running" ]; then - # 2. FastAPI Health Check 시도 - HEALTH=$(echo $PW | sudo -S docker exec "${CONTAINER_NAME}" curl -sf http://localhost:${INTERNAL_PORT}/docs/swagger 2>/dev/null || echo "") - - if [ -n "$HEALTH" ]; then - echo "✅ FastAPI 정상 기동 확인! (HTTP 응답)" - HEALTH_CHECK_PASSED=true - HEALTH_CHECK_METHOD="HTTP" - break + # 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만) + if [ -n "$HEALTH_PATH" ]; then + HEALTH=$(echo $PW | sudo -S docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "") + + if [ -n "$HEALTH" ]; then + echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="HTTP" + break + fi fi # 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백 - STARTED=$(echo $PW | sudo -S docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "Uvicorn running on|Application startup complete" || echo "") - - if [ -n "$STARTED" ]; then - echo "✅ FastAPI 정상 기동 확인! (로그 패턴)" - echo " $STARTED" - HEALTH_CHECK_PASSED=true - HEALTH_CHECK_METHOD="Log" - break + if [ -n "$LOG_PATTERN" ]; then + STARTED=$(echo $PW | sudo -S docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "") + + if [ -n "$STARTED" ]; then + echo "✅ 정상 기동 확인! (로그 패턴)" + echo " $STARTED" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="Log" + break + fi fi fi @@ -982,10 +1017,21 @@ jobs: const projectName = '${{ env.PROJECT_NAME }}'; const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; const previewPort = '${{ env.PREVIEW_PORT }}'; + const apiDocsPath = '${{ env.API_DOCS_PATH }}'; const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; const domain = `${projectName}-pr-${issueNumber}.${domainSuffix}`; const previewUrl = `http://${domain}:${previewPort}`; + // Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가) + const envRows = [ + `| **Preview URL** | ${previewUrl} |`, + ]; + if (apiDocsPath) { + envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`); + } + envRows.push(`| **컨테이너** | \`${projectName}-pr-${issueNumber}\` |`); + envRows.push(`| **브랜치** | \`${branchName}\` |`); + const body = [ '## ✅ Issue Preview 배포 완료!', '', @@ -999,10 +1045,7 @@ jobs: '### 🌐 Preview 환경', '| 항목 | 값 |', '|------|-----|', - `| **Preview URL** | ${previewUrl} |`, - `| **Swagger Docs** | ${previewUrl}/docs/swagger |`, - `| **컨테이너** | \`${projectName}-pr-${issueNumber}\` |`, - `| **브랜치** | \`${branchName}\` |`, + ...envRows, '', '### 📋 명령어', '| 명령어 | 설명 |', diff --git a/.github/workflows/project-types/spring/synology/PROJECT-SPRING-SYNOLOGY-PR-PREVIEW.yaml b/.github/workflows/project-types/spring/synology/PROJECT-SPRING-SYNOLOGY-PR-PREVIEW.yaml index 21a42ae..c8dd503 100644 --- a/.github/workflows/project-types/spring/synology/PROJECT-SPRING-SYNOLOGY-PR-PREVIEW.yaml +++ b/.github/workflows/project-types/spring/synology/PROJECT-SPRING-SYNOLOGY-PR-PREVIEW.yaml @@ -1,14 +1,18 @@ # =================================================================== -# Spring Boot + Synology PR Preview 시스템 +# Spring Boot + Synology PR/Issue Preview 시스템 # =================================================================== # -# PR 코멘트 명령어를 통해 Preview 환경을 자동으로 관리합니다. +# PR 또는 Issue 코멘트 명령어를 통해 Preview 환경을 자동으로 관리합니다. # Traefik 리버스 프록시를 통해 동적 라우팅됩니다. # # 🚀 사용법: -# @suh-lab pr build - PR 빌드 및 배포 (기존 컨테이너 자동 교체) -# @suh-lab pr destroy - Preview 환경 삭제 -# @suh-lab pr status - 현재 상태 확인 +# @suh-lab test build - PR/Issue 빌드 및 배포 (기존 컨테이너 자동 교체) +# @suh-lab test destroy - Preview 환경 삭제 +# @suh-lab test status - 현재 상태 확인 +# +# 📌 Issue에서 빌드 시: +# - Issue Helper 댓글에서 브랜치명을 자동으로 추출합니다. +# - ISSUE_HELPER_MARKER 환경변수로 마커를 설정할 수 있습니다. # # ⚙️ 프로젝트별 필수 수정 사항: # 아래 env 섹션의 값들을 프로젝트에 맞게 수정하세요. @@ -23,11 +27,12 @@ # - SERVER_USER: SSH 사용자명 # - SERVER_PASSWORD: SSH 비밀번호 # -# [선택] 프로젝트별 추가 (필요 없으면 해당 step 삭제): +# [선택] 프로젝트별 추가 (RomRom 예시 - 필요 없으면 삭제): # - VERTEX_SA_KEY: Vertex AI 서비스 계정 키 # - FIREBASE_KEY_JSON: Firebase Admin SDK 키 # - FIREBASE_MESSAGING_SW_JS: Firebase Messaging Service Worker -# - 기타 프로젝트별 Secret... +# - PRICE_PREDICTION_PROMPT_YML: 가격 예측 프롬프트 +# - ADMIN_YML: 관리자 설정 # # 📋 사전 요구사항: # - Traefik 컨테이너 실행 중 (traefik-network) @@ -49,36 +54,40 @@ # - Providers: Docker 프로바이더 상태 # # 배포 후 대시보드에서 새로운 Router/Service가 추가되었는지 확인할 수 있습니다. -# 예: Router "suh-project-utility-pr-123" → Service "suh-project-utility-pr-123" +# 예: Router "romrom-pr-123" → Service "romrom-pr-123" # # 📊 리소스 네이밍 규칙: -# - 컨테이너: {PROJECT_NAME}-pr-{PR번호} -# - 이미지: {DOCKERHUB_USERNAME}/{PROJECT_NAME}:pr-{PR번호} -# - 도메인: {PROJECT_NAME}-pr-{PR번호}.pr.suhsaechan.kr +# - 컨테이너: {PROJECT_NAME}-pr-{PR/Issue번호} +# - 이미지: {DOCKERHUB_USERNAME}/{PROJECT_NAME}:pr-{PR/Issue번호} +# - 도메인: {PROJECT_NAME}-pr-{PR/Issue번호}.pr.suhsaechan.kr # - Preview URL: http://{도메인}:8079 # +# 📝 Issue Helper 마커 예시: +# - RomRom: 'Chuseok22 issue helper' +# - 다른 프로젝트: 'SUH-ISSUE-HELPER' +# # =================================================================== name: PROJECT-SPRING-SYNOLOGY-PR-PREVIEW # =================================================================== -# ⚠️ [영역 1] 프로젝트별 설정 - 다른 프로젝트에서 사용 시 이 섹션만 수정하세요 +# ⚠️ 프로젝트별 설정 - 다른 프로젝트에서 사용 시 이 섹션만 수정하세요 # =================================================================== env: - # 프로젝트 고유 식별자 (컨테이너명, 이미지명, 도메인에 사용) (프로젝트별 수정 필요) - PROJECT_NAME: suh-project-utility + # 프로젝트 고유 식별자 (컨테이너명, 이미지명, 도메인에 사용)(프로젝트별 수정 필요) + PROJECT_NAME: romrom # Spring Boot 빌드 설정 (프로젝트별 수정 필요) JAVA_VERSION: '17' GRADLE_BUILD_CMD: './gradlew clean build -x test -Dspring.profiles.active=prod' - JAR_PATH: 'Suh-Web/build/libs/*.jar' - APPLICATION_YML_PATH: 'Suh-Web/src/main/resources/application-prod.yml' + JAR_PATH: 'RomRom-Web/build/libs/*.jar' + APPLICATION_YML_PATH: 'RomRom-Web/src/main/resources/application-prod.yml' # Docker 설정 (프로젝트별 수정 필요) DOCKERFILE_PATH: './Dockerfile' INTERNAL_PORT: '8080' - # Traefik & Preview 도메인 설정 (환경 구축 후 수정 금지) + # Traefik & Preview 도메인 설정 (환경 구축 후 수정 금지) TRAEFIK_NETWORK: traefik-network PREVIEW_DOMAIN_SUFFIX: pr.suhsaechan.kr PREVIEW_PORT: '8079' @@ -86,14 +95,20 @@ env: # SSH 포트 (시놀로지 포트 환경 구축 후 수정 금지) SSH_PORT: '2022' + # Issue Helper 댓글 마커 (프로젝트별 수정 필요) + # Issue 댓글에서 브랜치명을 추출할 때 사용 + ISSUE_HELPER_MARKER: 'Chuseok22 issue helper' + # =================================================================== # 트리거 설정 # =================================================================== on: issue_comment: - types: [created] + types: [created] # PR 댓글 + Issue 댓글 + issues: + types: [closed] # Issue 닫힘 시 destroy pull_request: - types: [closed] + types: [closed] # PR 닫힘 시 destroy permissions: contents: read @@ -105,18 +120,19 @@ permissions: # =================================================================== jobs: # ----------------------------------------------------------------- - # Job 1: 명령어 파싱 + # Job 1: 명령어 파싱 (PR 댓글 + Issue 댓글 모두 지원) # ----------------------------------------------------------------- check-command: name: 명령어 확인 - if: github.event_name == 'issue_comment' && github.event.issue.pull_request + if: github.event_name == 'issue_comment' runs-on: ubuntu-latest outputs: command: ${{ steps.parse.outputs.command }} is_valid: ${{ steps.parse.outputs.is_valid }} + is_pr: ${{ steps.parse.outputs.is_pr }} steps: - name: 댓글에 👀 리액션 추가 - if: contains(github.event.comment.body, '@suh-lab') && contains(github.event.comment.body, 'pr') + if: contains(github.event.comment.body, '@suh-lab') && contains(github.event.comment.body, 'test') uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -130,33 +146,136 @@ jobs: - name: 커맨드 파싱 id: parse + env: + COMMENT: ${{ github.event.comment.body }} + IS_PR: ${{ github.event.issue.pull_request }} run: | - COMMENT="${{ github.event.comment.body }}" + # PR인지 Issue인지 확인 + if [[ "$IS_PR" != "" ]]; then + echo "is_pr=true" >> $GITHUB_OUTPUT + echo "ℹ️ PR 댓글에서 명령어 감지" + else + echo "is_pr=false" >> $GITHUB_OUTPUT + echo "ℹ️ Issue 댓글에서 명령어 감지" + fi - if [[ "$COMMENT" =~ @suh-lab[[:space:]]+pr[[:space:]]+build ]]; then + # COMMENT 전달 + if [[ "$COMMENT" =~ @suh-lab[[:space:]]+test[[:space:]]+build ]]; then echo "command=build" >> $GITHUB_OUTPUT echo "is_valid=true" >> $GITHUB_OUTPUT echo "✅ 명령어 감지: build" - elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+pr[[:space:]]+destroy ]]; then + elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+test[[:space:]]+destroy ]]; then echo "command=destroy" >> $GITHUB_OUTPUT echo "is_valid=true" >> $GITHUB_OUTPUT echo "✅ 명령어 감지: destroy" - elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+pr[[:space:]]+status ]]; then + elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+test[[:space:]]+status ]]; then echo "command=status" >> $GITHUB_OUTPUT echo "is_valid=true" >> $GITHUB_OUTPUT echo "✅ 명령어 감지: status" else echo "is_valid=false" >> $GITHUB_OUTPUT - echo "ℹ️ @suh-lab pr 명령어가 아님" + echo "ℹ️ @suh-lab test 명령어가 아님" fi # ----------------------------------------------------------------- - # Job 2: 빌드 & 배포 + # Job 2: Issue에서 브랜치명 추출 # ----------------------------------------------------------------- - build-preview: - name: Preview 빌드 & 배포 + get-branch-from-issue: + name: Issue에서 브랜치 추출 needs: check-command - if: needs.check-command.outputs.is_valid == 'true' && needs.check-command.outputs.command == 'build' + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.is_pr == 'false' + runs-on: ubuntu-latest + outputs: + branch_name: ${{ steps.extract.outputs.branch }} + found: ${{ steps.extract.outputs.found }} + issue_number: ${{ steps.extract.outputs.issue_number }} + steps: + - name: Issue 댓글에서 브랜치명 추출 + id: extract + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.issue.number; + const marker = '${{ env.ISSUE_HELPER_MARKER }}'; + + console.log(`🔍 Issue #${issueNumber}에서 브랜치 검색 중...`); + console.log(`📝 마커: ${marker}`); + + // Issue의 모든 댓글 조회 + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + + // 브랜치 추출 패턴 (마커와 무관하게 동일한 형식) + // "### 브랜치" 또는 "### 브랜치명" 둘 다 매칭 + const branchRegex = /###\s*브랜치(?:명)?\s*\n```\s*\n([^\n]+)\s*\n```/; + + for (const comment of comments.data) { + // 마커가 포함된 댓글 찾기 + if (comment.body.includes(marker)) { + const match = comment.body.match(branchRegex); + if (match) { + const branchName = match[1].trim(); + core.setOutput('branch', branchName); + core.setOutput('found', 'true'); + core.setOutput('issue_number', issueNumber); + console.log(`✅ 브랜치 발견: ${branchName}`); + return; + } + } + } + + // 브랜치를 찾지 못한 경우 - 에러가 아님, graceful하게 처리 + core.setOutput('found', 'false'); + core.setOutput('issue_number', issueNumber); + console.log('⚠️ Issue Helper 댓글에서 브랜치를 찾을 수 없습니다.'); + + - name: 브랜치 없음 알림 + if: steps.extract.outputs.found == 'false' + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.issue.number; + const marker = '${{ env.ISSUE_HELPER_MARKER }}'; + + const body = [ + '## ⚠️ 브랜치를 찾을 수 없습니다', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **Issue** | #${issueNumber} |`, + `| **마커** | \`${marker}\` |`, + '', + '### 💡 확인 사항', + '1. Issue Helper 댓글이 존재하는지 확인하세요', + '2. 댓글에 `### 브랜치` 섹션이 있는지 확인하세요', + '3. `ISSUE_HELPER_MARKER` 환경변수가 올바른지 확인하세요', + '', + '---', + '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + + # ----------------------------------------------------------------- + # Job 3-1: 빌드 & 배포 (PR 댓글에서 실행) + # ----------------------------------------------------------------- + build-preview-pr: + name: Preview 빌드 & 배포 (PR) + needs: check-command + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'build' && + needs.check-command.outputs.is_pr == 'true' runs-on: ubuntu-latest steps: - name: PR 정보 가져오기 @@ -235,7 +354,7 @@ jobs: # 📋 선택 (프로젝트에 따라 추가/제거): # - Firebase: firebase-admin-sdk.json, firebase-messaging-sw.js # - Vertex AI: gen-lang-client-xxx.json - # - 기타 설정 파일: admin.yml, prompt.yml 등 + # - 기타 설정 파일: admin.yml, price-prediction.prompt.yml 등 # # ⚠️ 다른 프로젝트에서 사용 시: # 1. 필요 없는 step은 삭제하세요 @@ -246,23 +365,46 @@ jobs: # [필수] Spring Boot 설정 파일 - name: "[필수] application-prod.yml 생성" run: | - mkdir -p $(dirname ${{ env.APPLICATION_YML_PATH }}) + mkdir -p RomRom-Web/src/main/resources cat << 'EOF' > ${{ env.APPLICATION_YML_PATH }} ${{ secrets.APPLICATION_PROD_YML }} EOF - # [선택] 아래는 예시입니다. 필요 없으면 삭제하세요. - # - name: "[선택] Vertex AI Service Account Key 생성" - # env: - # VERTEX_SA_KEY: ${{ secrets.VERTEX_SA_KEY }} - # run: | - # echo "$VERTEX_SA_KEY" | sed 's/\\n/\n/g' > ./Suh-Web/src/main/resources/vertex-ai-key.json + # [선택] Vertex AI - 사용하지 않으면 이 step 삭제 + - name: "[선택] Vertex AI Service Account Key 생성" + env: + VERTEX_SA_KEY: ${{ secrets.VERTEX_SA_KEY }} + run: | + echo "$VERTEX_SA_KEY" | sed 's/\\n/\n/g' > ./RomRom-Web/src/main/resources/gen-lang-client-0511951522-e0dc1d68cbcb.json - # - name: "[선택] Firebase Admin SDK 생성" - # env: - # FIREBASE_KEY_JSON: ${{ secrets.FIREBASE_KEY_JSON }} - # run: | - # echo "$FIREBASE_KEY_JSON" | sed 's/\\n/\n/g' > ./Suh-Web/src/main/resources/firebase-admin-sdk.json + # [선택] Firebase - 사용하지 않으면 이 step 삭제 + - name: "[선택] Firebase Admin SDK 생성" + env: + FIREBASE_KEY_JSON: ${{ secrets.FIREBASE_KEY_JSON }} + run: | + echo "$FIREBASE_KEY_JSON" | sed 's/\\n/\n/g' > ./RomRom-Web/src/main/resources/firebase-admin-sdk.json + + # [선택] Firebase - 사용하지 않으면 이 step 삭제 + - name: "[선택] Firebase Messaging SW 생성" + run: | + mkdir -p RomRom-Web/src/main/resources/static + cat << 'EOF' > ./RomRom-Web/src/main/resources/static/firebase-messaging-sw.js + ${{ secrets.FIREBASE_MESSAGING_SW_JS }} + EOF + + # [선택] AI 프롬프트 - 사용하지 않으면 이 step 삭제 + - name: "[선택] price-prediction.prompt.yml 생성" + run: | + cat << 'EOF' > ./RomRom-Web/src/main/resources/price-prediction.prompt.yml + ${{ secrets.PRICE_PREDICTION_PROMPT_YML }} + EOF + + # [선택] 관리자 설정 - 사용하지 않으면 이 step 삭제 + - name: "[선택] admin.yml 생성" + run: | + cat << 'EOF' > ./RomRom-Web/src/main/resources/admin.yml + ${{ secrets.ADMIN_YML }} + EOF # ================================================================= # ⚠️ [영역 2 끝] 프로젝트별 Secret 파일 생성 끝 @@ -535,9 +677,9 @@ jobs: '### 📋 명령어', '| 명령어 | 설명 |', '|--------|------|', - '| `@suh-lab pr build` | 최신 커밋으로 재배포 |', - '| `@suh-lab pr destroy` | Preview 환경 삭제 |', - '| `@suh-lab pr status` | 현재 상태 확인 |', + '| `@suh-lab test build` | 최신 커밋으로 재배포 |', + '| `@suh-lab test destroy` | Preview 환경 삭제 |', + '| `@suh-lab test status` | 현재 상태 확인 |', '', '---', '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*' @@ -612,7 +754,7 @@ jobs: '', '### 💡 다음 단계', '1. 위 링크에서 빌드/배포 로그를 확인하세요', - '2. 문제를 수정한 후 다시 시도하세요: `@suh-lab pr build`', + '2. 문제를 수정한 후 다시 시도하세요: `@suh-lab test build`', '', '---', '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*' @@ -636,23 +778,605 @@ jobs: } # ----------------------------------------------------------------- - # Job 3: Preview 삭제 + # Job 3-2: 빌드 & 배포 (Issue 댓글에서 실행) + # ----------------------------------------------------------------- + build-preview-issue: + name: Preview 빌드 & 배포 (Issue) + needs: [check-command, get-branch-from-issue] + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'build' && + needs.check-command.outputs.is_pr == 'false' && + needs.get-branch-from-issue.outputs.found == 'true' + runs-on: ubuntu-latest + steps: + - name: 브랜치 존재 확인 + id: check_branch + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + + console.log(`🔍 브랜치 존재 확인: ${branchName}`); + + try { + await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: branchName + }); + core.setOutput('exists', 'true'); + console.log(`✅ 브랜치 존재: ${branchName}`); + } catch (error) { + if (error.status === 404) { + core.setOutput('exists', 'false'); + console.log(`❌ 브랜치 없음: ${branchName}`); + + // 브랜치 없음 알림 댓글 + const body = [ + '## ❌ 브랜치를 찾을 수 없습니다', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **Issue** | #${issueNumber} |`, + `| **브랜치** | \`${branchName}\` |`, + '', + '### 💡 확인 사항', + '1. 브랜치가 push되었는지 확인하세요', + '2. 브랜치명이 정확한지 확인하세요', + '3. 브랜치가 삭제되지 않았는지 확인하세요', + '', + '---', + '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + } else { + throw error; + } + } + + - name: 브랜치 없으면 중단 + if: steps.check_branch.outputs.exists == 'false' + run: | + echo "⚠️ 브랜치가 존재하지 않아 빌드를 건너뜁니다." + exit 0 + + - name: Issue 정보 설정 + if: steps.check_branch.outputs.exists == 'true' + id: issue + run: | + echo "ref=${{ needs.get-branch-from-issue.outputs.branch_name }}" >> $GITHUB_OUTPUT + echo "issue_number=${{ needs.get-branch-from-issue.outputs.issue_number }}" >> $GITHUB_OUTPUT + # 브랜치의 최신 커밋 SHA 가져오기 + echo "sha=$(git ls-remote https://github.com/${{ github.repository }}.git refs/heads/${{ needs.get-branch-from-issue.outputs.branch_name }} | cut -c1-7)" >> $GITHUB_OUTPUT + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 진행 상황 댓글 시스템 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + - name: 진행 상황 댓글 생성 + if: steps.check_branch.outputs.exists == 'true' + id: progress + uses: actions/github-script@v7 + with: + script: | + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const startTime = Date.now(); + + const body = [ + '## 🚀 Issue Preview 빌드 중...', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + '| 🔨 프로젝트 빌드 | ⏳ 진행 중... | - |', + '| 🐳 Docker 이미지 생성 | ⏸️ 대기 | - |', + '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + + core.setOutput('comment_id', comment.id); + core.setOutput('start_time', startTime); + + - name: 코드 체크아웃 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/checkout@v4 + with: + ref: ${{ needs.get-branch-from-issue.outputs.branch_name }} + + - name: JDK 설정 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ env.JAVA_VERSION }} + cache: 'gradle' + + - name: Gradle 권한 설정 + if: steps.check_branch.outputs.exists == 'true' + run: chmod +x gradlew + + # ================================================================= + # ⚠️ [영역 2] 프로젝트별 Secret 파일 생성 + # ================================================================= + + # [필수] Spring Boot 설정 파일 + - name: "[필수] application-prod.yml 생성" + if: steps.check_branch.outputs.exists == 'true' + run: | + mkdir -p RomRom-Web/src/main/resources + cat << 'EOF' > ${{ env.APPLICATION_YML_PATH }} + ${{ secrets.APPLICATION_PROD_YML }} + EOF + + # [선택] Vertex AI - 사용하지 않으면 이 step 삭제 + - name: "[선택] Vertex AI Service Account Key 생성" + if: steps.check_branch.outputs.exists == 'true' + env: + VERTEX_SA_KEY: ${{ secrets.VERTEX_SA_KEY }} + run: | + echo "$VERTEX_SA_KEY" | sed 's/\\n/\n/g' > ./RomRom-Web/src/main/resources/gen-lang-client-0511951522-e0dc1d68cbcb.json + + # [선택] Firebase - 사용하지 않으면 이 step 삭제 + - name: "[선택] Firebase Admin SDK 생성" + if: steps.check_branch.outputs.exists == 'true' + env: + FIREBASE_KEY_JSON: ${{ secrets.FIREBASE_KEY_JSON }} + run: | + echo "$FIREBASE_KEY_JSON" | sed 's/\\n/\n/g' > ./RomRom-Web/src/main/resources/firebase-admin-sdk.json + + # [선택] Firebase - 사용하지 않으면 이 step 삭제 + - name: "[선택] Firebase Messaging SW 생성" + if: steps.check_branch.outputs.exists == 'true' + run: | + mkdir -p RomRom-Web/src/main/resources/static + cat << 'EOF' > ./RomRom-Web/src/main/resources/static/firebase-messaging-sw.js + ${{ secrets.FIREBASE_MESSAGING_SW_JS }} + EOF + + # [선택] AI 프롬프트 - 사용하지 않으면 이 step 삭제 + - name: "[선택] price-prediction.prompt.yml 생성" + if: steps.check_branch.outputs.exists == 'true' + run: | + cat << 'EOF' > ./RomRom-Web/src/main/resources/price-prediction.prompt.yml + ${{ secrets.PRICE_PREDICTION_PROMPT_YML }} + EOF + + # [선택] 관리자 설정 - 사용하지 않으면 이 step 삭제 + - name: "[선택] admin.yml 생성" + if: steps.check_branch.outputs.exists == 'true' + run: | + cat << 'EOF' > ./RomRom-Web/src/main/resources/admin.yml + ${{ secrets.ADMIN_YML }} + EOF + + # ================================================================= + # ⚠️ [영역 2 끝] 프로젝트별 Secret 파일 생성 끝 + # ================================================================= + + - name: Gradle 빌드 + if: steps.check_branch.outputs.exists == 'true' + run: ${{ env.GRADLE_BUILD_CMD }} + + - name: 진행 상황 - 빌드 완료 + if: steps.check_branch.outputs.exists == 'true' + id: build_progress + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const startTime = ${{ steps.progress.outputs.start_time }}; + const now = Date.now(); + const elapsed = now - startTime; + const minutes = Math.floor(elapsed / 60000); + const seconds = Math.floor((elapsed % 60000) / 1000); + const buildDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const body = [ + '## 🚀 Issue Preview 빌드 중...', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + '| 🐳 Docker 이미지 생성 | ⏳ 진행 중... | - |', + '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + core.setOutput('build_duration', buildDuration); + core.setOutput('docker_start', now); + + - name: Docker 로그인 + if: steps.check_branch.outputs.exists == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker Buildx 설정 + if: steps.check_branch.outputs.exists == 'true' + uses: docker/setup-buildx-action@v3 + + - name: Docker 이미지 빌드 & Push + if: steps.check_branch.outputs.exists == 'true' + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:pr-${{ needs.get-branch-from-issue.outputs.issue_number }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 진행 상황 - Docker 완료 + if: steps.check_branch.outputs.exists == 'true' + id: docker_progress + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const buildDuration = '${{ steps.build_progress.outputs.build_duration }}'; + const dockerStart = ${{ steps.build_progress.outputs.docker_start }}; + const now = Date.now(); + const dockerElapsed = now - dockerStart; + const minutes = Math.floor(dockerElapsed / 60000); + const seconds = Math.floor((dockerElapsed % 60000) / 1000); + const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const body = [ + '## 🚀 Issue Preview 빌드 중...', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + `| 🐳 Docker 이미지 생성 | ✅ 완료 | ${dockerDuration} |`, + '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + core.setOutput('build_duration', buildDuration); + core.setOutput('docker_duration', dockerDuration); + core.setOutput('deploy_start', now); + + - name: 서버에 배포 + if: steps.check_branch.outputs.exists == 'true' + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + port: ${{ env.SSH_PORT }} + script: | + set -e + + # 환경 변수 설정 (시놀로지 NAS용) + export PATH=$PATH:/usr/local/bin + export PW="${{ secrets.SERVER_PASSWORD }}" + + # 변수 설정 + ISSUE_NUMBER=${{ needs.get-branch-from-issue.outputs.issue_number }} + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${ISSUE_NUMBER}" + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${ISSUE_NUMBER}" + DOMAIN="${PROJECT_NAME}-pr-${ISSUE_NUMBER}.${{ env.PREVIEW_DOMAIN_SUFFIX }}" + INTERNAL_PORT="${{ env.INTERNAL_PORT }}" + TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 Issue Preview 배포 시작" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📦 프로젝트: ${PROJECT_NAME}" + echo "🔢 Issue 번호: #${ISSUE_NUMBER}" + echo "📛 컨테이너: ${CONTAINER_NAME}" + echo "🌐 도메인: ${DOMAIN}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # 이미지 Pull + echo "📥 Docker 이미지 Pull 중..." + echo $PW | sudo -S docker pull "${IMAGE}" + + # 기존 컨테이너 삭제 + echo "🗑️ 기존 컨테이너 정리 중..." + echo $PW | sudo -S docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + + # 새 컨테이너 실행 + echo "🐳 새 컨테이너 실행 중..." + echo $PW | sudo -S docker run -d \ + --name "${CONTAINER_NAME}" \ + --network "${TRAEFIK_NETWORK}" \ + --label "traefik.enable=true" \ + --label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${DOMAIN}\`)" \ + --label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \ + --label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${INTERNAL_PORT}" \ + -e TZ=Asia/Seoul \ + -e SPRING_PROFILES_ACTIVE=prod \ + -v /etc/localtime:/etc/localtime:ro \ + "${IMAGE}" + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Health Check (최대 120초 대기) - 하이브리드 방식 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + echo "" + echo "⏳ Health Check 시작 (최대 120초 대기)..." + MAX_RETRIES=24 + RETRY_COUNT=0 + HEALTH_CHECK_PASSED=false + HEALTH_CHECK_METHOD="" + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + sleep 5 + RETRY_COUNT=$((RETRY_COUNT + 1)) + + # 1. 컨테이너 상태 확인 + STATUS=$(echo $PW | sudo -S docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found") + + if [ "$STATUS" = "exited" ]; then + echo "❌ 컨테이너 비정상 종료!" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo $PW | sudo -S docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + if [ "$STATUS" = "running" ]; then + # 2. Actuator Health Check 시도 + HEALTH=$(echo $PW | sudo -S docker exec "${CONTAINER_NAME}" wget -qO- http://localhost:${INTERNAL_PORT}/actuator/health 2>/dev/null || echo "") + + if echo "$HEALTH" | grep -q '"status":"UP"'; then + echo "✅ Spring Boot 정상 기동 확인! (Actuator)" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="Actuator" + break + fi + + # 3. Actuator 없으면 로그 패턴 매칭으로 폴백 + STARTED=$(echo $PW | sudo -S docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "Started .* in [0-9.]+ seconds" || echo "") + + if [ -n "$STARTED" ]; then + echo "✅ Spring Boot 정상 기동 확인! (로그 패턴)" + echo " $STARTED" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="Log" + break + fi + fi + + echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS" + done + + if [ "$HEALTH_CHECK_PASSED" = "false" ]; then + echo "" + echo "❌ Health Check 타임아웃 (120초)" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo $PW | sudo -S docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + - name: 배포 완료 코멘트 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const buildDuration = '${{ steps.docker_progress.outputs.build_duration }}'; + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + const deployStart = ${{ steps.docker_progress.outputs.deploy_start }}; + const now = Date.now(); + const deployElapsed = now - deployStart; + const minutes = Math.floor(deployElapsed / 60000); + const seconds = Math.floor((deployElapsed % 60000) / 1000); + const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + + const projectName = '${{ env.PROJECT_NAME }}'; + const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; + const previewPort = '${{ env.PREVIEW_PORT }}'; + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + const domain = `${projectName}-pr-${issueNumber}.${domainSuffix}`; + const previewUrl = `http://${domain}:${previewPort}`; + + const body = [ + '## ✅ Issue Preview 배포 완료!', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + `| 🐳 Docker 이미지 생성 | ✅ 완료 | ${dockerDuration} |`, + `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`, + '', + '### 🌐 Preview 환경', + '| 항목 | 값 |', + '|------|-----|', + `| **Preview URL** | ${previewUrl} |`, + `| **컨테이너** | \`${projectName}-pr-${issueNumber}\` |`, + `| **브랜치** | \`${branchName}\` |`, + '', + '### 📋 명령어', + '| 명령어 | 설명 |', + '|--------|------|', + '| `@suh-lab test build` | 최신 커밋으로 재배포 |', + '| `@suh-lab test destroy` | Preview 환경 삭제 |', + '| `@suh-lab test status` | 현재 상태 확인 |', + '', + '---', + '*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + - name: 빌드/배포 실패 시 에러 코멘트 + if: failure() && steps.check_branch.outputs.exists == 'true' + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id || 0 }}; + const projectName = '${{ env.PROJECT_NAME }}'; + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const buildDuration = '${{ steps.build_progress.outputs.build_duration }}'; + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + + let buildStatus, dockerStatus, deployStatus; + let buildTime = '-', dockerTime = '-'; + + if (!commentId) { + buildStatus = '⚠️ 초기화 실패'; + dockerStatus = '-'; + deployStatus = '-'; + } else if (!buildDuration || buildDuration === '') { + buildStatus = '❌ 실패'; + dockerStatus = '⏸️ 대기'; + deployStatus = '⏸️ 대기'; + } else if (!dockerDuration || dockerDuration === '') { + buildStatus = '✅ 완료'; + dockerStatus = '❌ 실패'; + deployStatus = '⏸️ 대기'; + buildTime = buildDuration; + } else { + buildStatus = '✅ 완료'; + dockerStatus = '✅ 완료'; + deployStatus = '❌ 실패'; + buildTime = buildDuration; + dockerTime = dockerDuration; + } + + const body = [ + '## ❌ Issue Preview 배포 실패!', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ${buildStatus} | ${buildTime} |`, + `| 🐳 Docker 이미지 생성 | ${dockerStatus} | ${dockerTime} |`, + `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`, + '', + `**[📋 빌드/배포 로그 확인](${runUrl})**`, + '', + '### 🔍 가능한 원인', + '- Gradle 빌드 실패', + '- Docker 이미지 빌드 실패', + '- 컨테이너 시작 실패 (Spring Boot 기동 오류)', + '- Health Check 타임아웃 (120초 내 기동 완료 안됨)', + '', + '### 💡 다음 단계', + '1. 위 링크에서 빌드/배포 로그를 확인하세요', + '2. 문제를 수정한 후 다시 시도하세요: `@suh-lab test build`', + '', + '---', + '*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + if (commentId) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + } + + # ----------------------------------------------------------------- + # Job 4: Preview 삭제 (PR/Issue 닫힘 또는 destroy 명령) # ----------------------------------------------------------------- destroy-preview: name: Preview 삭제 needs: check-command if: | - (github.event_name == 'issue_comment' && needs.check-command.outputs.is_valid == 'true' && needs.check-command.outputs.command == 'destroy') || - (github.event_name == 'pull_request' && github.event.action == 'closed') + always() && ( + (github.event_name == 'issue_comment' && needs.check-command.outputs.is_valid == 'true' && needs.check-command.outputs.command == 'destroy') || + (github.event_name == 'pull_request' && github.event.action == 'closed') || + (github.event_name == 'issues' && github.event.action == 'closed') + ) runs-on: ubuntu-latest steps: - - name: PR 번호 가져오기 + - name: PR/Issue 번호 가져오기 id: pr_number run: | if [[ "${{ github.event_name }}" == "issue_comment" ]]; then echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT - else + echo "type=comment" >> $GITHUB_OUTPUT + elif [[ "${{ github.event_name }}" == "pull_request" ]]; then echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + echo "type=pr_closed" >> $GITHUB_OUTPUT + else + echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT + echo "type=issue_closed" >> $GITHUB_OUTPUT fi - name: 컨테이너 & 이미지 삭제 @@ -694,7 +1418,7 @@ jobs: uses: actions/github-script@v7 with: script: | - const prNumber = context.issue.number; + const number = ${{ steps.pr_number.outputs.number }}; const projectName = '${{ env.PROJECT_NAME }}'; const body = [ @@ -702,24 +1426,24 @@ jobs: '', '| 항목 | 값 |', '|------|-----|', - `| **컨테이너** | \`${projectName}-pr-${prNumber}\` |`, + `| **컨테이너** | \`${projectName}-pr-${number}\` |`, '| **상태** | 삭제됨 |', '', - '다시 배포하려면: `@suh-lab pr build`', + '다시 배포하려면: `@suh-lab test build`', '', '---', - '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*' + '*🤖 이 댓글은 PR/Issue Preview 시스템에 의해 자동 생성되었습니다.*' ].join('\n'); - github.rest.issues.createComment({ + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: prNumber, + issue_number: number, body: body }); # ----------------------------------------------------------------- - # Job 4: 상태 확인 + # Job 5: 상태 확인 (PR/Issue 모두 지원) # ----------------------------------------------------------------- check-status: name: Preview 상태 확인 @@ -739,12 +1463,12 @@ jobs: # 환경 변수 설정 (시놀로지 NAS용) export PATH=$PATH:/usr/local/bin - PR_NUMBER=${{ github.event.issue.number }} + NUMBER=${{ github.event.issue.number }} PROJECT_NAME="${{ env.PROJECT_NAME }}" - CONTAINER_NAME="${PROJECT_NAME}-pr-${PR_NUMBER}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "🔍 PR Preview 상태 확인" + echo "🔍 Preview 상태 확인" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then @@ -769,22 +1493,24 @@ jobs: # 환경 변수 설정 (시놀로지 NAS용) export PATH=$PATH:/usr/local/bin - PR_NUMBER=${{ github.event.issue.number }} + NUMBER=${{ github.event.issue.number }} PROJECT_NAME="${{ env.PROJECT_NAME }}" - CONTAINER_NAME="${PROJECT_NAME}-pr-${PR_NUMBER}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}" docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}" - name: 상태 코멘트 작성 uses: actions/github-script@v7 with: script: | - const prNumber = context.issue.number; + const number = context.issue.number; const projectName = '${{ env.PROJECT_NAME }}'; const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; const previewPort = '${{ env.PREVIEW_PORT }}'; - const domain = `${projectName}-pr-${prNumber}.${domainSuffix}`; + const domain = `${projectName}-pr-${number}.${domainSuffix}`; const previewUrl = `http://${domain}:${previewPort}`; const isRunning = '${{ steps.check_running.outcome }}' === 'success'; + const isPr = '${{ needs.check-command.outputs.is_pr }}' === 'true'; + const contextType = isPr ? 'PR' : 'Issue'; let body; if (isRunning) { @@ -794,17 +1520,17 @@ jobs: '| 항목 | 값 |', '|------|-----|', `| **Preview URL** | ${previewUrl} |`, - `| **컨테이너** | \`${projectName}-pr-${prNumber}\` |`, + `| **컨테이너** | \`${projectName}-pr-${number}\` |`, '| **상태** | 🟢 Running |', '', '### 📋 명령어', '| 명령어 | 설명 |', '|--------|------|', - '| `@suh-lab pr build` | 최신 커밋으로 재배포 |', - '| `@suh-lab pr destroy` | Preview 환경 삭제 |', + '| `@suh-lab test build` | 최신 커밋으로 재배포 |', + '| `@suh-lab test destroy` | Preview 환경 삭제 |', '', '---', - '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*' + `*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*` ].join('\n'); } else { body = [ @@ -812,19 +1538,19 @@ jobs: '', '| 항목 | 값 |', '|------|-----|', - `| **컨테이너** | \`${projectName}-pr-${prNumber}\` |`, + `| **컨테이너** | \`${projectName}-pr-${number}\` |`, '| **상태** | 🔴 Not Found |', '', - '배포하려면: `@suh-lab pr build`', + '배포하려면: `@suh-lab test build`', '', '---', - '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*' + `*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*` ].join('\n'); } - github.rest.issues.createComment({ + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: prNumber, + issue_number: number, body: body }); diff --git a/CLAUDE.md b/CLAUDE.md index 83b1214..8443f2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,20 @@ uv run python -m src.main - **ffmpeg/ffprobe**: 오디오/비디오 처리에 필요 - **yt-dlp**: Instagram/YouTube 콘텐츠 다운로드에 사용 +## 네이밍 규칙 + +### 파일명 +- 파일명만 보고 역할을 알 수 있어야 함 +- 예: `base.py` ❌ → `playwright_browser.py` ✅ +- 예: `router.py` ❌ → `scrape_router.py` ✅ + +### 변수/함수명 +- 길어도 명확한 이름 선호 +- 축약어 사용 최소화 +- 예: `desc` ❌ → `description` ✅ +- 예: `res` ❌ → `response` ✅ +- 예: `cnt` ❌ → `count` ✅ + ## API 응답 규칙 - `success` 필드 사용 금지 - HTTP 상태 코드로 성공/실패 판단 diff --git a/pyproject.toml b/pyproject.toml index d5b9943..3cdd8dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "google-genai>=1.39.1", "imagehash>=4.3.2", "pillow>=12.0.0", + "playwright>=1.49.0", "pydantic-settings>=2.11.0", "uvicorn[standard]>=0.37.0", "yt-dlp>=2025.10.22", diff --git a/src/apis/test_router.py b/src/apis/test_router.py new file mode 100644 index 0000000..8f7b87d --- /dev/null +++ b/src/apis/test_router.py @@ -0,0 +1,35 @@ +"""src.apis.test_router +테스트 API 라우터 - SNS 스크래핑 테스트용 +""" +import logging +from fastapi import APIRouter +from pydantic import BaseModel + +from src.services.scraper.scrape_router import route_and_scrape + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/test", tags=["테스트 API"]) + + +class ScrapeRequest(BaseModel): + url: str + + +@router.post("/scrape", status_code=200) +async def scrape_url(request: ScrapeRequest): + """ + SNS URL에서 메타데이터를 Playwright로 스크래핑 + + - POST /api/test/scrape + - Body: {"url": "https://www.instagram.com/p/..."} + - 성공: 200 + 메타데이터 + - 실패: 4xx/5xx + 에러 메시지 + """ + logger.info(f"스크래핑 요청: {request.url}") + return await route_and_scrape(request.url) + + +@router.get("/health", status_code=200) +async def health_check(): + """스크래핑 테스트 API 상태 확인""" + return {"status": "ok"} diff --git a/src/main.py b/src/main.py index f3f0d84..40abf4d 100644 --- a/src/main.py +++ b/src/main.py @@ -7,6 +7,7 @@ from fastapi import FastAPI, Request from src.core.logging import setup_logging from src.apis.place_router import router as place_router +from src.apis.test_router import router as test_router # 로깅 초기화 setup_logging(log_level="INFO") @@ -45,6 +46,7 @@ async def lifespan(app: FastAPI): # 라우터 등록 app.include_router(place_router) +app.include_router(test_router) @app.middleware("http") diff --git a/src/services/scraper/__init__.py b/src/services/scraper/__init__.py new file mode 100644 index 0000000..145977d --- /dev/null +++ b/src/services/scraper/__init__.py @@ -0,0 +1,6 @@ +"""src.services.scraper +SNS 스크래핑 서비스 패키지 +""" +from src.services.scraper.scrape_router import route_and_scrape + +__all__ = ["route_and_scrape"] diff --git a/src/services/scraper/platforms/__init__.py b/src/services/scraper/platforms/__init__.py new file mode 100644 index 0000000..b621975 --- /dev/null +++ b/src/services/scraper/platforms/__init__.py @@ -0,0 +1,7 @@ +"""src.services.scraper.platforms +플랫폼별 스크래퍼 패키지 +""" +from src.services.scraper.platforms.instagram_scraper import InstagramScraper +from src.services.scraper.platforms.youtube_scraper import YouTubeScraper + +__all__ = ["InstagramScraper", "YouTubeScraper"] diff --git a/src/services/scraper/platforms/instagram_scraper.py b/src/services/scraper/platforms/instagram_scraper.py new file mode 100644 index 0000000..cf6a364 --- /dev/null +++ b/src/services/scraper/platforms/instagram_scraper.py @@ -0,0 +1,168 @@ +"""src.services.scraper.platforms.instagram_scraper +Instagram 스크래핑 로직 +""" +import re +import logging +from playwright.async_api import async_playwright +from fastapi import HTTPException + +from src.services.scraper.playwright_browser import PlaywrightBrowser +from src.utils.url_classifier import UrlClassification + +logger = logging.getLogger(__name__) + + +class InstagramScraper: + """Instagram 게시글/릴스 스크래퍼""" + + def __init__(self): + self.browser_controller = PlaywrightBrowser() + + def parse_instagram_description(self, description: str) -> dict: + """ + og:description에서 메타데이터 파싱 + + 예: "7,434 likes, 63 comments - jamsilism on September 24, 2025: \"캡션...\"" + + Args: + description: og:description 내용 + + Returns: + dict: 파싱된 메타데이터 + """ + if not description: + return { + "author": None, + "likes_count": None, + "comments_count": None, + "posted_at": None, + "caption": None, + "hashtags": [] + } + + # 좋아요 수 파싱 + likes_match = re.search(r'([\d,]+)\s*likes?', description) + likes_count = int(likes_match.group(1).replace(',', '')) if likes_match else None + + # 댓글 수 파싱 + comments_match = re.search(r'([\d,]+)\s*comments?', description) + comments_count = int(comments_match.group(1).replace(',', '')) if comments_match else None + + # 작성자 파싱 (언더스코어, 점 포함) + author_match = re.search(r'-\s*([\w.]+)\s+on\s+', description) + author = author_match.group(1) if author_match else None + + # 게시 날짜 파싱 + date_match = re.search(r'on\s+([\w\s,]+?):', description) + posted_at = date_match.group(1).strip() if date_match else None + + # 캡션 본문 추출 (좋아요/댓글 정보 이후 부분) + caption_match = re.search(r':\s*["\']?(.+)', description, re.DOTALL) + caption = caption_match.group(1).rstrip('"\'') if caption_match else description + + # 해시태그 추출 (한글 해시태그 포함) + hashtags = re.findall(r'#[\w가-힣]+', description) + + return { + "author": author, + "likes_count": likes_count, + "comments_count": comments_count, + "posted_at": posted_at, + "caption": caption, + "hashtags": hashtags + } + + async def extract_instagram_image_urls(self) -> list[str]: + """ + Instagram 이미지 URL 추출 (cdninstagram.com 도메인만) + + Returns: + list[str]: 이미지 URL 목록 + """ + image_urls = await self.browser_controller.page.evaluate('''() => { + const imgs = document.querySelectorAll('img[src*="cdninstagram.com"]'); + const urls = []; + imgs.forEach(img => { + const src = img.src; + // 프로필 이미지 제외 (보통 작은 크기) + if (src && !src.includes('150x150') && !src.includes('44x44')) { + urls.push(src); + } + }); + // 중복 제거 + return [...new Set(urls)]; + }''') + logger.info(f"이미지 URL 추출: {len(image_urls)}개") + return image_urls + + async def scrape_instagram_post(self, url: str, classification: UrlClassification) -> dict: + """ + Instagram 게시글/릴스 스크래핑 + + Args: + url: Instagram URL + classification: URL 분류 결과 + + Returns: + dict: 스크래핑 결과 + + Raises: + HTTPException: 스크래핑 실패 시 + """ + logger.info(f"[1/5] Instagram 스크래핑 시작: {url} (type={classification.content_type})") + + async with async_playwright() as playwright: + try: + # [2/5] 브라우저 생성 + logger.info("[2/5] 브라우저 초기화...") + await self.browser_controller.create_browser_and_context(playwright) + + # [3/5] 페이지 로드 + logger.info("[3/5] 페이지 로드...") + response = await self.browser_controller.load_page(url) + + if response and response.status >= 400: + logger.error(f"Instagram 응답 오류: {response.status}") + raise HTTPException( + status_code=response.status, + detail=f"Instagram 응답 오류: {response.status}" + ) + + # [4/5] 메타데이터 추출 + logger.info("[4/5] 메타데이터 추출...") + open_graph_metadata = await self.browser_controller.extract_open_graph_tags() + + # og:description 파싱 + parsed_metadata = self.parse_instagram_description( + open_graph_metadata.get('description', '') + ) + logger.info( + f"메타데이터 파싱 완료: author={parsed_metadata['author']}, " + f"likes={parsed_metadata['likes_count']}, comments={parsed_metadata['comments_count']}" + ) + + # [5/5] 이미지 URL 추출 + logger.info("[5/5] 이미지 URL 추출...") + image_urls = await self.extract_instagram_image_urls() + + return { + "platform": classification.platform, + "content_type": classification.content_type, + "url": url, + "author": parsed_metadata["author"], + "caption": parsed_metadata["caption"], + "likes_count": parsed_metadata["likes_count"], + "comments_count": parsed_metadata["comments_count"], + "posted_at": parsed_metadata["posted_at"], + "hashtags": parsed_metadata["hashtags"], + "og_image": open_graph_metadata.get('image'), + "image_urls": image_urls + } + + except HTTPException: + raise + except Exception as error: + logger.error(f"Instagram 스크래핑 오류: {error}", exc_info=True) + raise HTTPException(status_code=500, detail=str(error)) + finally: + await self.browser_controller.close_browser() diff --git a/src/services/scraper/platforms/youtube_scraper.py b/src/services/scraper/platforms/youtube_scraper.py new file mode 100644 index 0000000..cf84de3 --- /dev/null +++ b/src/services/scraper/platforms/youtube_scraper.py @@ -0,0 +1,30 @@ +"""src.services.scraper.platforms.youtube_scraper +YouTube 스크래핑 로직 (미구현) +""" +import logging +from fastapi import HTTPException + +from src.utils.url_classifier import UrlClassification + +logger = logging.getLogger(__name__) + + +class YouTubeScraper: + """YouTube 비디오/쇼츠 스크래퍼 (미구현)""" + + async def scrape_youtube_video(self, url: str, classification: UrlClassification) -> dict: + """ + YouTube 비디오/쇼츠 스크래핑 (미구현) + + Args: + url: YouTube URL + classification: URL 분류 결과 + + Raises: + HTTPException(501): 아직 구현되지 않음 + """ + logger.warning(f"YouTube 스크래핑 요청 (미구현): {url}") + raise HTTPException( + status_code=501, + detail="YouTube 스크래핑은 아직 구현되지 않았습니다" + ) diff --git a/src/services/scraper/playwright_browser.py b/src/services/scraper/playwright_browser.py new file mode 100644 index 0000000..509968b --- /dev/null +++ b/src/services/scraper/playwright_browser.py @@ -0,0 +1,78 @@ +"""src.services.scraper.playwright_browser +Playwright 브라우저 공통 제어 모듈 +""" +import logging +from playwright.async_api import async_playwright, Page, Browser, BrowserContext + +logger = logging.getLogger(__name__) + + +class PlaywrightBrowser: + """Playwright 브라우저 공통 로직""" + + def __init__(self): + self.browser: Browser | None = None + self.context: BrowserContext | None = None + self.page: Page | None = None + + async def create_browser_and_context(self, playwright) -> None: + """브라우저와 컨텍스트 생성""" + logger.info("브라우저 실행 중...") + self.browser = await playwright.chromium.launch(headless=True) + + logger.info("페이지 컨텍스트 생성...") + self.context = await self.browser.new_context( + user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + viewport={"width": 1920, "height": 1080} + ) + self.page = await self.context.new_page() + + async def load_page(self, url: str, wait_until: str = "networkidle", timeout: int = 30000): + """ + 페이지 로드 + + Args: + url: 로드할 URL + wait_until: 대기 조건 (networkidle, load, domcontentloaded) + timeout: 타임아웃 (밀리초) + + Returns: + Response: 페이지 응답 객체 + """ + logger.info(f"페이지 로드 중: {url}") + response = await self.page.goto(url, wait_until=wait_until, timeout=timeout) + + if response: + logger.info(f"페이지 로드 완료: status={response.status}") + else: + logger.warning("페이지 로드 완료: response=None") + + return response + + async def extract_open_graph_tags(self) -> dict: + """ + og 메타 태그 추출 + + Returns: + dict: og 태그 딕셔너리 (title, description, image, url) + """ + metadata = await self.page.evaluate('''() => { + const result = {}; + const ogTags = ['og:title', 'og:description', 'og:image', 'og:url']; + ogTags.forEach(tag => { + const meta = document.querySelector(`meta[property="${tag}"]`); + if (meta) result[tag.replace('og:', '')] = meta.content; + }); + return result; + }''') + logger.info(f"og 메타 태그 추출 완료: {list(metadata.keys())}") + return metadata + + async def close_browser(self) -> None: + """브라우저 종료""" + if self.browser: + logger.info("브라우저 종료") + await self.browser.close() + self.browser = None + self.context = None + self.page = None diff --git a/src/services/scraper/scrape_router.py b/src/services/scraper/scrape_router.py new file mode 100644 index 0000000..df1b83a --- /dev/null +++ b/src/services/scraper/scrape_router.py @@ -0,0 +1,41 @@ +"""src.services.scraper.scrape_router +URL을 분석하여 적절한 플랫폼 스크래퍼로 라우팅 +""" +import logging + +from src.utils.url_classifier import classify_url +from src.services.scraper.platforms.instagram_scraper import InstagramScraper +from src.services.scraper.platforms.youtube_scraper import YouTubeScraper + +logger = logging.getLogger(__name__) + + +async def route_and_scrape(url: str) -> dict: + """ + URL을 분석하여 적절한 스크래퍼로 라우팅하고 스크래핑 수행 + + Args: + url: SNS URL + + Returns: + dict: 스크래핑 결과 + + Raises: + HTTPException: 스크래핑 실패 또는 지원하지 않는 URL + """ + # URL 분류 (지원하지 않는 URL이면 400 에러) + classification = classify_url(url) + logger.info(f"URL 분류 완료: platform={classification.platform}, type={classification.content_type}") + + # 플랫폼별 스크래퍼 라우팅 + if classification.platform == "instagram": + scraper = InstagramScraper() + return await scraper.scrape_instagram_post(url, classification) + + elif classification.platform == "youtube": + scraper = YouTubeScraper() + return await scraper.scrape_youtube_video(url, classification) + + # 이 코드는 classify_url에서 이미 예외를 던지므로 도달하지 않음 + # 하지만 타입 안전성을 위해 유지 + raise ValueError(f"지원하지 않는 플랫폼: {classification.platform}") diff --git a/src/utils/url_classifier.py b/src/utils/url_classifier.py new file mode 100644 index 0000000..746ec1e --- /dev/null +++ b/src/utils/url_classifier.py @@ -0,0 +1,59 @@ +"""src.utils.url_classifier +URL 분류 유틸리티 - SNS 플랫폼 및 콘텐츠 타입 감지 +""" +from urllib.parse import urlparse +from dataclasses import dataclass +from fastapi import HTTPException + + +@dataclass +class UrlClassification: + """URL 분류 결과""" + platform: str # "instagram", "youtube" + content_type: str # "post", "reel", "igtv", "video", "shorts" + url: str + + +def classify_url(url: str) -> UrlClassification: + """ + URL을 분석하여 플랫폼과 콘텐츠 타입을 분류 + + Args: + url: SNS URL + + Returns: + UrlClassification: 분류 결과 + + Raises: + HTTPException(400): 지원하지 않는 URL인 경우 + """ + parsed = urlparse(url) + domain = parsed.netloc.lower() + path = parsed.path.lower() + + # Instagram + if 'instagram.com' in domain: + if '/p/' in path: + return UrlClassification("instagram", "post", url) + elif '/reel/' in path or '/reels/' in path: + return UrlClassification("instagram", "reel", url) + elif '/tv/' in path: + return UrlClassification("instagram", "igtv", url) + else: + raise HTTPException( + status_code=400, + detail=f"지원하지 않는 Instagram URL 형식: {path}" + ) + + # YouTube + if 'youtube.com' in domain or 'youtu.be' in domain: + if '/shorts/' in path: + return UrlClassification("youtube", "shorts", url) + else: + return UrlClassification("youtube", "video", url) + + # 지원하지 않는 플랫폼 + raise HTTPException( + status_code=400, + detail=f"지원하지 않는 플랫폼: {domain}" + ) diff --git a/uv.lock b/uv.lock index 711ab3c..25fe72e 100644 --- a/uv.lock +++ b/uv.lock @@ -384,6 +384,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/02/858bdae08e2184b6afe0b18bc3113318522c9cf326a5a1698055edd31f88/google_genai-1.57.0-py3-none-any.whl", hash = "sha256:d63c7a89a1f549c4d14032f41a0cdb4b6fe3f565e2eee6b5e0907a0aeceabefd", size = 713323, upload-time = "2026-01-07T20:38:18.051Z" }, ] +[[package]] +name = "greenlet" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, + { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -540,6 +571,7 @@ dependencies = [ { name = "httpx" }, { name = "imagehash" }, { name = "pillow" }, + { name = "playwright" }, { name = "pydantic-settings" }, { name = "smbprotocol" }, { name = "uvicorn", extra = ["standard"] }, @@ -554,6 +586,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "imagehash", specifier = ">=4.3.2" }, { name = "pillow", specifier = ">=12.0.0" }, + { name = "playwright", specifier = ">=1.49.0" }, { name = "pydantic-settings", specifier = ">=2.11.0" }, { name = "smbprotocol", specifier = ">=1.13.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.37.0" }, @@ -708,6 +741,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, ] +[[package]] +name = "playwright" +version = "1.57.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039, upload-time = "2025-12-09T08:06:18.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575, upload-time = "2025-12-09T08:06:22.105Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042, upload-time = "2025-12-09T08:06:25.357Z" }, + { url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252, upload-time = "2025-12-09T08:06:29.186Z" }, + { url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917, upload-time = "2025-12-09T08:06:32.549Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860, upload-time = "2025-12-09T08:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864, upload-time = "2025-12-09T08:06:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940, upload-time = "2025-12-09T08:06:42.268Z" }, +] + [[package]] name = "protobuf" version = "6.33.2" @@ -835,6 +887,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + [[package]] name = "pyreadline3" version = "3.5.4"