Files

1 line
16 KiB
JSON

{"repo": "111usionbin/jtbc-2025", "files": {"stt.py": {"723cce2e209d1d780fa29ad79712893674c3475f": {"t": 1785674453.4108083, "n": 13611, "h": "77faf491b0e9b9e7", "c": "import os\nfrom openai import OpenAI\nfrom dotenv import load_dotenv\nfrom pathlib import Path\nimport psycopg2\nfrom psycopg2.extras import RealDictCursor\nimport tempfile\nimport time\nimport random\nimport httpx # 신규: OpenAI 클라이언트에 프록시/환경제어 적용\nimport yt_dlp\n\n# .env 파일 로드 (스크립트 폴더 -> 레포 루트 -> 기본 검색)\nscript_env = Path(__file__).with_name(\".env\")\nrepo_root_env = Path(__file__).resolve().parents[1] / \".env\"\nloaded_env_path = None\nif script_env.exists():\n load_dotenv(dotenv_path=script_env)\n loaded_env_path = script_env\nelif repo_root_env.exists():\n load_dotenv(dotenv_path=repo_root_env)\n loaded_env_path = repo_root_env\nelse:\n load_dotenv() # fallback: dotenv 기본 검색\n loaded_env_path = \"default search\"\n\n# 환경 변수 설정 (앞뒤 공백/따옴표 제거; 내부 공백/따옴표는 오류)\nSUPABASE_CONNECTION_STRING = os.getenv(\"SUPABASE_CONNECTION_STRING\")\n_raw_key = os.getenv(\"OPENAI_API_KEY\") or \"\"\n_key = _raw_key.strip()\n# 제거 가능한 둘러싼 따옴표/backtick\nif (_key.startswith('\"') and _key.endswith('\"')) or (_key.startswith(\"'\") and _key.endswith(\"'\")) or (_key.startswith(\"`\") and _key.endswith(\"`\")):\n _key = _key[1:-1].strip()\nOPENAI_API_KEY = _key\n\n# OpenAI 관련 추가 환경변수 (없으면 빈 문자열)\nOPENAI_BASE_URL = (os.getenv(\"OPENAI_BASE_URL\") or \"\").strip()\nOPENAI_ORG_ID = (os.getenv(\"OPENAI_ORG_ID\") or \"\").strip()\nOPENAI_PROJECT_ID = (os.getenv(\"OPENAI_PROJECT_ID\") or \"\").strip()\nOPENAI_PROXY = (os.getenv(\"OPENAI_PROXY\") or \"\").strip()\n\n# 네트워크/다운로드 튜닝용 환경 변수\nYTDLP_PROXY = os.getenv(\"YTDLP_PROXY\") # 예: http://127.0.0.1:7890\nYTDLP_COOKIEFILE = os.getenv(\"YTDLP_COOKIEFILE\") # 예: c:\\path\\to\\cookies.txt\nYTDLP_SLEEP_MIN = int(os.getenv(\"YTDLP_SLEEP_MIN\", \"1\"))\nYTDLP_SLEEP_MAX = int(os.getenv(\"YTDLP_SLEEP_MAX\", \"3\"))\nYTDLP_MAX_ATTEMPTS = int(os.getenv(\"YTDLP_MAX_ATTEMPTS\", \"5\"))\nYTDLP_BACKOFF_BASE = float(os.getenv(\"YTDLP_BACKOFF_BASE\", \"2\"))\n\ndef _mask_key(k: str) -> str:\n if not k:\n return \"None\"\n return f\"{k[:6]}...{k[-4:]}\"\n\ndef _check_key_format(k: str):\n # 빈값/포맷/내부 공백·따옴표 체크\n if not k:\n raise SystemExit(\"OPENAI_API_KEY 가 비어있습니다. .env 위치/변수명을 확인하세요.\")\n if not (k.startswith(\"sk-\") or k.startswith(\"sk_proj-\") or k.startswith(\"sk-proj-\")):\n raise SystemExit(\"OPENAI_API_KEY 포맷이 올바르지 않습니다. sk- 또는 sk-proj- 로 시작하는 키를 사용하세요.\")\n # 내부 공백/따옴표는 허용하지 않음 (둘러싼 따옴표는 위에서 제거됨)\n for bad in ['\"', \"'\", \"`\", \" \"]:\n if bad in k:\n raise SystemExit(\"OPENAI_API_KEY 값에 내부 따옴표/공백이 포함되어 있습니다. .env에서 제거 후 다시 실행하세요.\")\n\n_check_key_format(OPENAI_API_KEY)\n\n# sk-proj- 키는 프로젝트 ID가 필수\nif OPENAI_API_KEY.startswith(\"sk-proj-\") and not OPENAI_PROJECT_ID:\n print(\"경고: sk-proj- 키를 사용 중이지만 OPENAI_PROJECT_ID가 설정되지 않았습니다.\")\n print(\"OpenAI 대시보드에서 프로젝트 ID를 확인하여 .env에 추가하세요.\")\n print(\"예: OPENAI_PROJECT_ID=proj_xxxxxxxxxxxx\\n\")\n\n# OpenAI 클라이언트 초기화 (환경 프록시 무시 기본, 명시적 OPENAI_PROXY가 있을 때만 사용)\nUSE_HTTPX_TRUST_ENV = False\nif OPENAI_PROXY:\n # 명시적 프록시가 설정된 경우 환경변수로 전달하고 trust_env 활성화\n os.environ[\"HTTPS_PROXY\"] = OPENAI_PROXY\n os.environ[\"HTTP_PROXY\"] = OPENAI_PROXY\n USE_HTTPX_TRUST_ENV = True\n\n# httpx 버전에 따라 trust_env 지원 여부 처리\n# 긴 영상(1시간 이상)의 STT 처리를 위해 타임아웃을 6300초(1시간 45분)로 설정\ntry:\n http_client = httpx.Client(timeout=6300.0, trust_env=USE_HTTPX_TRUST_ENV)\nexcept TypeError:\n # 일부 오래된/특정 버전은 trust_env 인자를 지원하지 않음\n http_client = httpx.Client(timeout=6300.0)\n\nopenai_client = OpenAI(\n api_key=OPENAI_API_KEY,\n base_url=OPENAI_BASE_URL or None,\n organization=OPENAI_ORG_ID or None,\n project=OPENAI_PROJECT_ID or None,\n http_client=http_client,\n)\n\ndef validate_openai_credentials():\n \"\"\"키 유효성을 빠르게 점검합니다. 잘못된 키면 즉시 종료.\"\"\"\n try:\n base = OPENAI_BASE_URL or \"https://api.openai.com/v1 (default)\"\n proxy = OPENAI_PROXY or os.getenv(\"HTTPS_PROXY\") or os.getenv(\"HTTP_PROXY\") or \"none\"\n # 로드된 .env 경로 정보가 있으면 표시\n try:\n loaded_info = loaded_env_path if isinstance(loaded_env_path, str) else str(loaded_env_path)\n except NameError:\n loaded_info = \"unknown\"\n # 간단한 호출로 인증 검증\n openai_client.models.list()\n print(f\"OpenAI 키 확인 완료: {_mask_key(OPENAI_API_KEY)} | base_url={base} | proxy={proxy} | trust_env={'on' if USE_HTTPX_TRUST_ENV else 'off'} | .env={loaded_info}\")\n except Exception as e:\n msg = str(e)\n # 가능한 경우 예외에서 상태코드 확인\n status_code = getattr(e, \"status_code\", None)\n if status_code is None and hasattr(e, \"response\") and getattr(e.response, \"status_code\", None):\n status_code = getattr(e.response, \"status_code\")\n if status_code == 401 or \"invalid_api_key\" in msg or \"status': 401\" in msg or \"Incorrect API key provided\" in msg or \"HTTP status code: 401\" in msg:\n print(f\"OpenAI 인증 실패(401). 현재 키: {_mask_key(OPENAI_API_KEY)} | base_url={OPENAI_BASE_URL or 'default'} | proxy={OPENAI_PROXY or 'none'} | trust_env={'on' if USE_HTTPX_TRUST_ENV else 'off'}\")\n print(f\"\\n=== 401 오류 해결 체크리스트 ===\")\n print(f\"1. .env 파일이 올바른 위치에 있는지 확인: {loaded_env_path}\")\n print(f\"2. .env에서 OPENAI_API_KEY 값 확인 (위 출력된 전체 키 확인)\")\n print(f\"3. OpenAI 대시보드(https://platform.openai.com/api-keys)에서:\")\n print(f\" - 키가 활성 상태인지 확인\")\n print(f\" - 사용 한도(Usage limits)가 설정되어 있는지 확인\")\n print(f\" - 결제 정보가 등록되어 있는지 확인\")\n if OPENAI_API_KEY.startswith(\"sk-proj-\"):\n print(f\"4. 프로젝트 키(sk-proj-)를 사용 중이므로 OPENAI_PROJECT_ID 필수:\")\n print(f\" 현재 값: {OPENAI_PROJECT_ID or '(설정 안 됨)'}\")\n print(f\" 대시보드 > Settings > General에서 Project ID 확인\")\n print(f\"5. 시스템 프록시 설정 제거 후 재시도\")\n print(f\"6. 다른 네트워크(모바일 핫스팟 등)에서 시도\")\n print(f\"================================\\n\")\n print(\"- .env 파일 경로/로딩 확인 (스크립트 폴더의 .env 사용)\")\n print(\"- 키에 공백/줄바꿈/따옴표가 포함되어 있지 않은지 확인\")\n print(\"- 필요 시 OPENAI_PROXY를 설정, 없다면 시스템 프록시 제거\")\n print(\"- 최신 openai/httpx로 업데이트 권장 (pip install -U openai httpx)\")\n raise SystemExit(1)\n raise\n\ndef get_db_connection():\n \"\"\"PostgreSQL 데이터베이스 연결을 반환합니다.\"\"\"\n return psycopg2.connect(SUPABASE_CONNECTION_STRING)\n\ndef build_ydl_opts(output_path: str) -> dict:\n \"\"\"yt-dlp 옵션을 구성합니다.\"\"\"\n opts = {\n 'format': 'bestaudio[ext=m4a]/bestaudio/best',\n 'postprocessors': [{\n 'key': 'FFmpegExtractAudio',\n 'preferredcodec': 'mp3',\n 'preferredquality': '32', # STT 전용으로 32kbps로 설정 (최대 압축)\n }],\n 'postprocessor_args': [\n '-ar', '8000', # 샘플레이트 8kHz로 낮춤 (음성 인식에 충분, 파일 크기 최소화)\n '-ac', '1', # 모노로 변환 (STT에는 스테레오 불필요)\n ],\n 'outtmpl': output_path,\n 'quiet': True,\n 'noplaylist': True,\n 'socket_timeout': 30,\n 'retries': 10,\n 'fragment_retries': 10,\n 'concurrent_fragment_downloads': 1,\n 'sleep_interval': YTDLP_SLEEP_MIN,\n 'max_sleep_interval': YTDLP_SLEEP_MAX,\n 'geo_bypass': True,\n 'http_headers': {\n # 일부 네트워크/차단 회피용 UA 지정\n 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'\n },\n 'extractor_args': {\n 'youtube': {\n # 플레이어 클라이언트 변경으로 차단 회피 시도\n 'player_client': ['android', 'web']\n }\n },\n }\n if YTDLP_PROXY:\n opts['proxy'] = YTDLP_PROXY\n if YTDLP_COOKIEFILE and Path(YTDLP_COOKIEFILE).exists():\n opts['cookiefile'] = YTDLP_COOKIEFILE\n return opts\n\ndef download_audio(video_id: str, output_path: str) -> str:\n \"\"\"유튜브 영상의 오디오를 다운로드합니다.\"\"\"\n url = f\"https://www.youtube.com/watch?v={video_id}\"\n ydl_opts = build_ydl_opts(output_path)\n\n last_err = None\n for attempt in range(1, YTDLP_MAX_ATTEMPTS + 1):\n try:\n with yt_dlp.YoutubeDL(ydl_opts) as ydl:\n ydl.download([url])\n return f\"{output_path}.mp3\"\n except Exception as e:\n last_err = e\n delay = YTDLP_BACKOFF_BASE * (2 ** (attempt - 1)) + random.uniform(0, 1.0)\n print(f\" - 다운로드 재시도 {attempt}/{YTDLP_MAX_ATTEMPTS} 예정, 대기 {delay:.1f}s: {e}\")\n time.sleep(delay)\n\n raise RuntimeError(f\"오디오 다운로드 실패 ({video_id}): {last_err}\")\n\ndef split_audio_file(audio_path: str, chunk_duration_minutes: int = 10) -> list:\n \"\"\"오디오 파일을 여러 청크로 분할합니다. (pydub 사용)\"\"\"\n try:\n from pydub import AudioSegment\n except ImportError:\n raise RuntimeError(\"pydub 패키지가 필요합니다. 설치: pip install pydub\")\n \n audio = AudioSegment.from_mp3(audio_path)\n chunk_length_ms = chunk_duration_minutes * 60 * 1000 # 분을 밀리초로 변환\n \n chunks = []\n for i in range(0, len(audio), chunk_length_ms):\n chunk = audio[i:i + chunk_length_ms]\n chunk_path = f\"{audio_path}_chunk_{i//chunk_length_ms}.mp3\"\n chunk.export(chunk_path, format=\"mp3\", bitrate=\"32k\", parameters=[\"-ar\", \"8000\", \"-ac\", \"1\"])\n chunks.append(chunk_path)\n \n return chunks\n\ndef transcribe_audio(audio_path: str) -> str:\n \"\"\"OpenAI Whisper API를 사용하여 오디오를 텍스트로 변환합니다.\"\"\"\n # Whisper API는 최대 25MB 파일만 지원\n file_size_mb = os.path.getsize(audio_path) / (1024 * 1024)\n \n # 25MB 초과 시 자동으로 분할 처리\n if file_size_mb > 25:\n print(f\" - 파일 크기({file_size_mb:.1f}MB)가 25MB 초과, 자동 분할 처리 중...\")\n chunk_files = split_audio_file(audio_path, chunk_duration_minutes=10)\n print(f\" - {len(chunk_files)}개 청크로 분할 완료\")\n \n transcripts = []\n for idx, chunk_path in enumerate(chunk_files, 1):\n try:\n chunk_size_mb = os.path.getsize(chunk_path) / (1024 * 1024)\n print(f\" - 청크 {idx}/{len(chunk_files)} 처리 중 ({chunk_size_mb:.1f}MB)...\")\n \n with open(chunk_path, \"rb\") as audio_file:\n transcript = openai_client.audio.transcriptions.create(\n model=\"whisper-1\",\n file=audio_file,\n language=\"ko\"\n )\n transcripts.append(transcript.text)\n finally:\n # 청크 파일 삭제\n if os.path.exists(chunk_path):\n os.remove(chunk_path)\n \n # 청크 간 짧은 대기\n if idx < len(chunk_files):\n time.sleep(1)\n \n print(f\" - 모든 청크 처리 완료, 텍스트 결합 중...\")\n return \" \".join(transcripts)\n \n # 25MB 이하는 일반 처리\n print(f\" - 파일 크기: {file_size_mb:.1f}MB (직접 처리)\")\n with open(audio_path, \"rb\") as audio_file:\n try:\n transcript = openai_client.audio.transcriptions.create(\n model=\"whisper-1\",\n file=audio_file,\n language=\"ko\"\n )\n except Exception as e:\n msg = str(e)\n if \"invalid_api_key\" in msg or \"status': 401\" in msg or \"Incorrect API key provided\" in msg or \"HTTP status code: 401\" in msg:\n raise RuntimeError(\"OpenAI 401: API 키가 올바르지 않거나 프록시로 인해 손상되었습니다.\")\n raise\n return transcript.text\n\ndef get_videos_without_transcript(table_name: str = \"videos\"):\n \"\"\"대본이 없는 영상 목록을 가져옵니다.\"\"\"\n conn = get_db_connection()\n try:\n with conn.cursor(cursor_factory=RealDictCursor) as cur:\n cur.execute(f\"SELECT id, video_id FROM {table_name} WHERE transcript IS NULL\")\n return cur.fetchall()\n finally:\n conn.close()\n\ndef update_transcript(video_id: str, transcript: str, table_name: str = \"videos\"):\n \"\"\"영상의 대본을 업데이트합니다.\"\"\"\n conn = get_db_connection()\n try:\n with conn.cursor() as cur:\n cur.execute(\n f\"UPDATE {table_name} SET transcript = %s WHERE video_id = %s\",\n (transcript, video_id)\n )\n conn.commit()\n finally:\n conn.close()\n\ndef main():\n \"\"\"메인 실행 함수\"\"\"\n # OpenAI 인증을 먼저 검증하여 대량 처리 전에 즉시 실패\n validate_openai_credentials()\n\n # 대본이 없는 영상 목록 조회\n videos = get_videos_without_transcript()\n \n if not videos:\n print(\"대본이 필요한 영상이 없습니다.\")\n return\n \n print(f\"총 {len(videos)}개의 영상 대본을 추출합니다.\")\n \n # 임시 디렉토리 생성\n with tempfile.TemporaryDirectory() as temp_dir:\n for idx, video in enumerate(videos, 1):\n video_id = video['video_id']\n print(f\"[{idx}/{len(videos)}] 영상 {video_id} 처리 중...\")\n \n try:\n # 오디오 다운로드\n audio_path = os.path.join(temp_dir, video_id)\n downloaded_file = download_audio(video_id, audio_path)\n print(f\" - 오디오 다운로드 완료\")\n \n # STT 수행\n transcript = transcribe_audio(downloaded_file)\n print(f\" - 대본 추출 완료 (길이: {len(transcript)} 자)\")\n \n # Supabase 업데이트\n update_transcript(video_id, transcript)\n print(f\" - DB 업데이트 완료\")\n \n # 임시 파일 삭제\n if os.path.exists(downloaded_file):\n os.remove(downloaded_file)\n \n except Exception as e:\n print(f\" - 오류 발생: {str(e)}\")\n # 키 오류면 추가 시도 의미 없으므로 중단\n if \"401\" in str(e) or \"invalid_api_key\" in str(e):\n print(\" - 인증 오류로 작업을 중단합니다.\")\n break\n # 다음 요청 전에 잠시 대기하여 차단/레이트리밋을 피합니다.\n time.sleep(random.uniform(YTDLP_SLEEP_MIN, YTDLP_SLEEP_MAX))\n continue\n\n # 각 영상 사이에도 짧게 대기\n time.sleep(random.uniform(YTDLP_SLEEP_MIN, YTDLP_SLEEP_MAX))\n\n print(\"모든 영상 처리 완료!\")\n\nif __name__ == \"__main__\":\n main()\n"}}}}