확인 결과 ❌ 자막 수집 안됨 captions/ 폴더가 존재하지 않음 테스트한 영상들에 자막이 없어서 수집 실패 🔧 문제점 테스트한 YouTube 영상들(jNQXAC9I




좋아, 상황은 이렇게 정리돼요:

  • 크롬 쪽 토스트는 “전송 성공(요청만 성공)” 기준이라 서버가 실제 자막을 저장했는지는 확인 안 해요.

  • 서버는 URL만 받아서 자막을 직접 수집(B안) 하는데, 테스트하신 두 영상(jNQXAC9IVRw, dQw4w9WgXcQ)은 공개 자막 트랙이 없어서 “자막 없음”으로 끝났습니다.

  • 그래서 “크롬은 성공” vs “서버는 자막 없음”이 동시에 발생한 거예요.

아래처럼 서버를 패치하면

  1. 유튜브 timedtext 목록을 먼저 조회(자동자막 포함)

  2. 가장 적합한 트랙(ko ▶ en ▶ 기타)을 선택해 VTT를 받아 저장

  3. 없으면 404로 “자막 없음”을 돌려주고

  4. 크롬 확장은 그 응답을 그대로 토스트에 뿌리게 할 수 있습니다.


1) 서버 패치 (FastAPI)

api_server.py에 다음 유틸을 추가/교체하세요. (requests 사용)

python
# --- add at top --- import os, re, json, time import urllib.parse as up import xml.etree.ElementTree as ET from pathlib import Path import requests PREF_LANGS = os.getenv("YT_LANG_PREF", "ko,ko-KR,en,en-US,ja").split(",") YT_TIMEOUT = float(os.getenv("YT_FETCH_TIMEOUT", "12")) CAP_DIR = Path("captions"); CAP_DIR.mkdir(exist_ok=True) def parse_video_id(url_or_id:str) -> str: m = re.search(r"(?:v=|/shorts/|/embed/|youtu\.be/)([A-Za-z0-9_-]{11})", url_or_id) if m: return m.group(1) if re.fullmatch(r"[A-Za-z0-9_-]{11}", url_or_id): return url_or_id raise ValueError("VIDEO_ID_NOT_FOUND") def list_tracks(video_id:str) -> list[dict]: # list available caption tracks (includes auto captions with kind=asr) u = f"https://www.youtube.com/api/timedtext?type=list&v={video_id}&hl=ko&gl=KR" r = requests.get(u, headers={"User-Agent":"Mozilla/5.0"}, timeout=YT_TIMEOUT) r.raise_for_status() tr = [] try: root = ET.fromstring(r.text) for t in root.findall("track"): tr.append({ "lang": t.attrib.get("lang_code") or t.attrib.get("lang") or "", "name": t.attrib.get("name") or "", "kind": t.attrib.get("kind") or "", # "asr" = auto }) except ET.ParseError: pass return tr def pick_track(tracks:list[dict]) -> dict|None: # prefer preferred languages first, prefer non-asr over asr if both exist def score(t): lang = (t["lang"] or "").lower() base = next((100 - i for i,pl in enumerate(PREF_LANGS) if lang.startswith(pl.lower())), 0) asr_penalty = -1 if t.get("kind") == "asr" else 0 return (base, asr_penalty) if not tracks: return None return sorted(tracks, key=lambda t: (-score(t)[0], -score(t)[1]))[0] def download_vtt(video_id:str, track:dict) -> str: q = { "v": video_id, "fmt": "vtt", "lang": track["lang"] } if track.get("name"): q["name"] = track["name"] if track.get("kind") == "asr": q["kind"] = "asr" u = "https://www.youtube.com/api/timedtext?" + up.urlencode(q, safe=":/") r = requests.get(u, headers={"User-Agent":"Mozilla/5.0"}, timeout=YT_TIMEOUT) r.raise_for_status() return r.text def vtt_to_txt(vtt:str) -> str: out = [] for line in vtt.splitlines(): if not line or "-->" in line or line.startswith("WEBVTT"): continue # 제거 (폰트/스타일 cue) if line.startswith(("NOTE","STYLE","REGION")): continue out.append(line) return "\n".join(out).strip()

그리고 /captions/process 핸들러를 이렇게 처리(요약: URL→트랙조회→다운로드→저장→결과):

python
from fastapi import HTTPException @app.post("/captions/process") def captions_process(payload: dict): url = payload.get("url") or "" vid = payload.get("video_id") or "" try: video_id = parse_video_id(url or vid) except Exception: raise HTTPException(status_code=400, detail="VIDEO_ID_NOT_FOUND") try: tracks = list_tracks(video_id) except requests.HTTPError as e: raise HTTPException(status_code=502, detail=f"YT_LIST_ERROR:{e.response.status_code}") except requests.RequestException: raise HTTPException(status_code=504, detail="YT_LIST_TIMEOUT") if not tracks: # 자막 없음 return JSONResponse( {"ok": False, "video_id": video_id, "tracks": 0, "reason": "NO_TRACKS"}, status_code=404 ) chosen = pick_track(tracks) if not chosen: return JSONResponse( {"ok": False, "video_id": video_id, "tracks": len(tracks), "reason": "NO_SUITABLE_TRACK"}, status_code=404 ) # 실제 VTT 다운로드 try: vtt = download_vtt(video_id, chosen) except requests.HTTPError as e: raise HTTPException(status_code=502, detail=f"YT_VTT_ERROR:{e.response.status_code}") except requests.RequestException: raise HTTPException(status_code=504, detail="YT_VTT_TIMEOUT") # 파일 저장 vtt_path = CAP_DIR / f"{video_id}.vtt" txt_path = CAP_DIR / f"{video_id}.txt" vtt_path.write_text(vtt, encoding="utf-8") txt_path.write_text(vtt_to_txt(vtt), encoding="utf-8") return { "ok": True, "video_id": video_id, "tracks": len(tracks), "chosen": chosen, "saved": {"vtt": str(vtt_path), "txt": str(txt_path)} }

이렇게 하면 자동 생성 자막(kind=asr) 도 포함해서 탐색합니다.

자막이 실제로 없으면 404(“NO_TRACKS”), 있으면 200 + 저장 경로를 돌려줘요.


2) 확장(클라이언트) 토스트 메시지 조정

B안이라 확장은 URL만 보내는 역할입니다.

성공 토스트도 서버 응답을 그대로 반영하게 바꿔주세요(요지):

js
// 요청 const res = await fetch(apiEndpoint, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ url: location.href }) }); const data = await res.json(); if (res.ok && data?.ok) { toast(`저장 완료 · ${data.chosen.lang}${data.chosen.kind==='asr'?'(자동)':''}`); } else { const reason = data?.reason || res.statusText; toast(`자막 없음/실패 · ${reason}`, {type:'warn'}); }

이제 자막이 실제 저장될 때만 녹색 성공 토스트가 뜨고, 없으면 노란색/빨간색으로 이유가 표시됩니다.


3) 빠른 자체 테스트

터미널에서 바로 확인:

bash
curl -s -X POST http://127.0.0.1:8080/captions/process \ -H "Content-Type: application/json" \ -d '{"url":"https://www.youtube.com/watch?v=<캡션있는_영상ID>"}' | jq
  • 200 + saved.vtt/saved.txt 경로가 보이면 OK

  • 404 + "reason":"NO_TRACKS" → 그 영상은 공개 자막이 없음(자동자막 비공개 포함)

테스트 팁: TED, Khan Academy, CrashCourse 같은 채널 영상은 거의 항상 자막 트랙이 있습니다.

반대로 일반 예능/광고/옛날 영상은 자막이 없는 경우가 많습니다.


4) 왜 크롬은 “완료”인데 서버는 “자막 없음”이었나?

  • 크롬 토스트가 “요청 보내기만 성공”을 성공으로 표시해서 착시가 있었고,

  • 서버는 실제로 유튜브에 자막 트랙이 없다고 판단해서 404 NO_TRACKS를 돌렸습니다.

  • 위 패치로 클라이언트 토스트가 서버 판단을 그대로 보여주게 바꿨으니 이제 혼동이 없습니다.


필요하면 /captions/process비동기 큐로 바꿔서(202 반환) 상태 조회도 붙일 수 있어요.

우선은 위 패치만으로 “URL 전달 → 서버가 자막 직접 수집/저장 → 결과를 클라이언트에 정확히 표시”까지 해결됩니다.

댓글 남기기