Update
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
YouTube Transcript Extractor — Astra extension companion script.
|
||||
|
||||
용도:
|
||||
채널 / 플레이리스트 / 단일 영상 URL을 받아 각 영상의 자막을 추출하고
|
||||
사용자가 지정한 폴더에 *영상 제목 + 영상 ID* 가 들어간 파일명으로 저장.
|
||||
|
||||
호출 방식 (Astra TypeScript 측에서 spawn):
|
||||
python youtube_transcript.py \\
|
||||
--source <URL> \\
|
||||
--output-dir <폴더 경로> \\
|
||||
[--lang ko,en] \\
|
||||
[--limit 50]
|
||||
|
||||
stdout으로 진행 상황을 *한 줄 한 JSON*씩 흘려서 TS가 stream 파싱하기 쉽게.
|
||||
각 라인은 다음 중 하나의 event:
|
||||
|
||||
{"type":"start","total":N,"source":"..."}
|
||||
{"type":"video","index":i,"video_id":"...","title":"...","status":"ok|fail","saved_to":"...","error":"..."}
|
||||
{"type":"done","ok":N_ok,"fail":N_fail,"output_dir":"..."}
|
||||
{"type":"error","stage":"...","message":"..."}
|
||||
|
||||
의존성:
|
||||
pip install yt-dlp youtube-transcript-api
|
||||
|
||||
사용자 환경에 패키지가 없으면 import 단계에서 {"type":"error"} JSON 한 줄 찍고
|
||||
exit 2. TS가 그것 보고 친절한 안내 메시지 표시.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Windows에서 stdout 기본 인코딩이 cp949로 잡히면 한글 JSON이 깨져서 TS 측이
|
||||
# 못 읽거나 화면에 �?? 로 표시된다. 가장 먼저 stdout/stderr를 UTF-8로 강제.
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
||||
except Exception:
|
||||
pass # 매우 오래된 Python에선 reconfigure 미지원 — TS 측 환경변수 fallback
|
||||
|
||||
|
||||
def _emit(event: dict) -> None:
|
||||
"""JSON 한 줄을 stdout에 흘리고 즉시 flush — TS의 stream reader가 line 단위로 받음."""
|
||||
sys.stdout.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _trace(stage: str, **info) -> None:
|
||||
"""디버그 trace — stderr로 흘려 TS 측 stderrTail에 누적된다. 사용자가 '자세히
|
||||
보기'로 stderr 확인 시 어느 단계까지 갔는지 한눈에. video event 누락 같은
|
||||
'조용한 실패'를 추적할 수 있다."""
|
||||
detail = " ".join(f"{k}={v}" for k, v in info.items())
|
||||
sys.stderr.write(f"[trace] {stage}: {detail}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def _check_deps():
|
||||
"""필수 패키지 import 가능 여부 검사 — 없으면 친절한 메시지로 종료."""
|
||||
missing = []
|
||||
try:
|
||||
import yt_dlp # noqa: F401
|
||||
except ImportError:
|
||||
missing.append("yt-dlp")
|
||||
try:
|
||||
from youtube_transcript_api import YouTubeTranscriptApi # noqa: F401
|
||||
except ImportError:
|
||||
missing.append("youtube-transcript-api")
|
||||
if missing:
|
||||
_emit({
|
||||
"type": "error",
|
||||
"stage": "deps",
|
||||
"message": f"필수 패키지가 없습니다: {', '.join(missing)}",
|
||||
"install_command": f"pip install {' '.join(missing)}",
|
||||
})
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _safe_filename(name: str, max_len: int = 100) -> str:
|
||||
"""Windows + macOS + Linux 모두에서 안전한 파일명. 일부 특수문자 제거 + 길이 cap."""
|
||||
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", name)
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
if len(name) > max_len:
|
||||
name = name[:max_len].rstrip()
|
||||
return name or "untitled"
|
||||
|
||||
|
||||
def _list_videos(source_url: str, limit: int | None) -> list[dict]:
|
||||
"""yt-dlp로 채널/플레이리스트의 영상 목록(또는 단일 영상)을 메타데이터까지 수집.
|
||||
|
||||
Return 형식: [{"id": "...", "title": "...", "url": "..."}]
|
||||
"""
|
||||
import yt_dlp
|
||||
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
# 전체 메타데이터를 펴는 대신 *flat playlist*로 영상 목록만 빠르게.
|
||||
"extract_flat": "in_playlist",
|
||||
"skip_download": True,
|
||||
}
|
||||
if limit and limit > 0:
|
||||
ydl_opts["playlistend"] = limit
|
||||
|
||||
videos: list[dict] = []
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(source_url, download=False)
|
||||
if not info:
|
||||
return []
|
||||
# 단일 영상 vs 채널/플레이리스트 구분
|
||||
if info.get("_type") in (None, "video"):
|
||||
# 단일 영상.
|
||||
vid = info.get("id") or ""
|
||||
title = info.get("title") or ""
|
||||
url = info.get("webpage_url") or f"https://www.youtube.com/watch?v={vid}"
|
||||
if vid:
|
||||
videos.append({"id": vid, "title": title, "url": url})
|
||||
else:
|
||||
# 채널/플레이리스트.
|
||||
entries = info.get("entries") or []
|
||||
for entry in entries:
|
||||
if not entry:
|
||||
continue
|
||||
vid = entry.get("id") or ""
|
||||
if not vid:
|
||||
continue
|
||||
title = entry.get("title") or vid
|
||||
url = entry.get("url") or f"https://www.youtube.com/watch?v={vid}"
|
||||
# `url`이 그냥 id인 경우(extract_flat 결과) 풀 URL로 변환.
|
||||
if not url.startswith("http"):
|
||||
url = f"https://www.youtube.com/watch?v={url}"
|
||||
videos.append({"id": vid, "title": title, "url": url})
|
||||
return videos
|
||||
|
||||
|
||||
def _list_transcripts_compat(video_id: str):
|
||||
"""youtube-transcript-api 0.6.x / 1.x 양쪽 지원.
|
||||
0.6.x: classmethod `YouTubeTranscriptApi.list_transcripts(video_id)`
|
||||
1.x: instance method `YouTubeTranscriptApi().list(video_id)`
|
||||
한 라이브러리만 설치돼 있을 수 있으니 두 방식 모두 시도."""
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
# 1.x 방식 먼저 시도 (사용자가 upgrade했다면 이쪽일 확률).
|
||||
if hasattr(YouTubeTranscriptApi, "list_transcripts"):
|
||||
# 0.6.x — classmethod.
|
||||
_trace("transcript_api", api="0.6.x classmethod")
|
||||
try:
|
||||
return YouTubeTranscriptApi.list_transcripts(video_id)
|
||||
except TypeError:
|
||||
# 1.x인데 호환용 stub만 있는 경우 — instance로 다시 시도.
|
||||
pass
|
||||
api = YouTubeTranscriptApi()
|
||||
if hasattr(api, "list"):
|
||||
_trace("transcript_api", api="1.x instance.list")
|
||||
return api.list(video_id)
|
||||
if hasattr(api, "list_transcripts"):
|
||||
_trace("transcript_api", api="fallback instance.list_transcripts")
|
||||
return api.list_transcripts(video_id)
|
||||
raise RuntimeError("youtube-transcript-api의 list API를 찾지 못했습니다 — 패키지 손상 가능")
|
||||
|
||||
|
||||
def _fetch_via_transcript_api(video_id: str, languages: list[str]) -> str:
|
||||
"""1차 시도: youtube-transcript-api. 빠르지만 YouTube 변경에 자주 깨짐."""
|
||||
from youtube_transcript_api.formatters import TextFormatter
|
||||
|
||||
_trace("transcript_api.start", video_id=video_id, langs=",".join(languages))
|
||||
transcript_list = _list_transcripts_compat(video_id)
|
||||
chosen = None
|
||||
for lang in languages:
|
||||
try:
|
||||
chosen = transcript_list.find_manually_created_transcript([lang])
|
||||
_trace("transcript_api.found", kind="manual", lang=lang)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if chosen is None:
|
||||
for lang in languages:
|
||||
try:
|
||||
chosen = transcript_list.find_generated_transcript([lang])
|
||||
_trace("transcript_api.found", kind="generated", lang=lang)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if chosen is None:
|
||||
try:
|
||||
chosen = next(iter(transcript_list))
|
||||
_trace("transcript_api.found", kind="first-available")
|
||||
except StopIteration:
|
||||
raise RuntimeError("자막 트랙이 없음")
|
||||
formatter = TextFormatter()
|
||||
fetched = chosen.fetch()
|
||||
text = formatter.format_transcript(fetched)
|
||||
_trace("transcript_api.ok", chars=len(text))
|
||||
return text
|
||||
|
||||
|
||||
def _fetch_via_yt_dlp(video_id: str, languages: list[str]) -> str:
|
||||
"""2차 fallback: yt-dlp가 직접 자막 파일을 다운로드. transcript-api보다 *훨씬* 안정적
|
||||
— YouTube 페이지를 직접 파싱하므로 라이브러리 호환성 이슈 영향 적음.
|
||||
|
||||
yt-dlp는 자막을 VTT/SRV3 등 다양한 포맷으로 받는데, VTT를 받아 plain text로
|
||||
변환한다. 자동 자막(`writeautomaticsub`)도 같이 요청해서 수동 자막이 없을 때도
|
||||
가져온다.
|
||||
"""
|
||||
import tempfile
|
||||
import yt_dlp
|
||||
_trace("yt_dlp.start", video_id=video_id, langs=",".join(languages))
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outtmpl = os.path.join(tmpdir, "%(id)s.%(ext)s")
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"skip_download": True,
|
||||
"writesubtitles": True,
|
||||
"writeautomaticsub": True,
|
||||
"subtitleslangs": languages + [f"{l}.*" for l in languages] + ["en"],
|
||||
"subtitlesformat": "vtt/best",
|
||||
"outtmpl": outtmpl,
|
||||
# 자막 다운로드 단계에서 HTTP 에러(429 등)가 SystemExit으로 빠져
|
||||
# 프로세스 전체를 죽이지 않도록. main loop가 예외를 잡아 video
|
||||
# 이벤트로 emit할 수 있게 한다.
|
||||
"ignoreerrors": True,
|
||||
# 429 등 일시적 실패 자동 재시도. 너무 공격적이면 IP block 위험,
|
||||
# 너무 느슨하면 사용자가 답답함 — 2회 정도가 적당.
|
||||
"retries": 2,
|
||||
"extractor_retries": 2,
|
||||
# 429 직격타 대응. 영상 사이에 1~3초 대기로 rate limit 회피.
|
||||
"sleep_interval": 1,
|
||||
"max_sleep_interval": 3,
|
||||
"sleep_interval_subtitles": 1,
|
||||
}
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([url])
|
||||
_trace("yt_dlp.download_returned")
|
||||
except SystemExit as e:
|
||||
# ignoreerrors=True여도 일부 분기에서 SystemExit이 raise됨 — 명시적으로 catch.
|
||||
_trace("yt_dlp.systemexit", code=getattr(e, "code", "?"))
|
||||
raise RuntimeError(f"yt-dlp SystemExit (코드={e.code})")
|
||||
# 결과: <video_id>.<lang>.vtt 형태로 떨어짐. 우선순위 lang 순서대로 찾음.
|
||||
vtt_path = None
|
||||
for lang in languages:
|
||||
for cand in Path(tmpdir).glob(f"{video_id}*{lang}*.vtt"):
|
||||
vtt_path = cand
|
||||
break
|
||||
if vtt_path:
|
||||
break
|
||||
# 못 찾으면 그냥 첫 vtt
|
||||
if vtt_path is None:
|
||||
for cand in Path(tmpdir).glob(f"{video_id}*.vtt"):
|
||||
vtt_path = cand
|
||||
break
|
||||
if vtt_path is None:
|
||||
raise RuntimeError("yt-dlp가 자막 파일을 만들지 못했습니다 — 영상에 자막이 정말 없거나 비공개")
|
||||
return _vtt_to_text(vtt_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _vtt_to_text(vtt: str) -> str:
|
||||
"""WebVTT를 plain text로. 타임스탬프 / 헤더 / 큐 식별자 / 빈줄 정리."""
|
||||
lines: list[str] = []
|
||||
prev = ""
|
||||
for raw in vtt.split("\n"):
|
||||
s = raw.rstrip()
|
||||
if not s:
|
||||
continue
|
||||
# WEBVTT 헤더 / NOTE 블록 / STYLE 블록 skip
|
||||
if s.startswith("WEBVTT") or s.startswith("NOTE") or s.startswith("STYLE") or s.startswith("Kind:") or s.startswith("Language:"):
|
||||
continue
|
||||
# 타임스탬프 라인 (00:00:00.000 --> 00:00:00.000) skip
|
||||
if "-->" in s and re.search(r"\d\d:\d\d", s):
|
||||
continue
|
||||
# 큐 식별자 (숫자 한 줄)
|
||||
if re.fullmatch(r"\d+", s):
|
||||
continue
|
||||
# VTT 인라인 태그 제거 (<00:00:00.000>, <c>, </c> 등)
|
||||
clean = re.sub(r"<[^>]+>", "", s).strip()
|
||||
if not clean:
|
||||
continue
|
||||
# 자동 자막은 같은 줄을 반복 출력하는 경우가 많음 — 직전 줄과 동일하면 skip
|
||||
if clean == prev:
|
||||
continue
|
||||
lines.append(clean)
|
||||
prev = clean
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fetch_transcript(video_id: str, languages: list[str]) -> str:
|
||||
"""1차 youtube-transcript-api → 실패하면 2차 yt-dlp fallback.
|
||||
|
||||
두 라이브러리의 실패 이유는 서로 달라서 fallback이 의미 있음:
|
||||
- transcript-api: YouTube의 내부 자막 endpoint 변화에 자주 깨짐
|
||||
- yt-dlp: 영상 페이지 자체를 파싱하므로 endpoint 변화에 강함, 더 잘 유지보수됨
|
||||
각 단계 trace는 stderr로. 모두 실패한 경우 errors 메시지를 하나로 합쳐 raise.
|
||||
BaseException으로 잡아 SystemExit/import-time error도 포함.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
try:
|
||||
return _fetch_via_transcript_api(video_id, languages)
|
||||
except BaseException as e:
|
||||
msg = f"{type(e).__name__}: {e}"
|
||||
_trace("transcript_api.fail", msg=msg)
|
||||
errors.append(f"transcript-api: {msg}")
|
||||
try:
|
||||
return _fetch_via_yt_dlp(video_id, languages)
|
||||
except BaseException as e:
|
||||
msg = f"{type(e).__name__}: {e}"
|
||||
_trace("yt_dlp.fail", msg=msg)
|
||||
errors.append(f"yt-dlp: {msg}")
|
||||
raise RuntimeError(" / ".join(errors) or "자막을 가져오지 못했습니다")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="YouTube transcript bulk extractor")
|
||||
parser.add_argument("--source", required=True, help="채널 / 플레이리스트 / 단일 영상 URL")
|
||||
parser.add_argument("--output-dir", required=True, help="자막 파일 저장 폴더")
|
||||
parser.add_argument("--lang", default="ko,en", help="자막 언어 우선순위 (콤마 구분)")
|
||||
parser.add_argument("--limit", type=int, default=0, help="최대 영상 수 (0 = 제한 없음)")
|
||||
args = parser.parse_args()
|
||||
|
||||
_check_deps()
|
||||
|
||||
languages = [s.strip() for s in args.lang.split(",") if s.strip()]
|
||||
if not languages:
|
||||
languages = ["ko", "en"]
|
||||
|
||||
output_dir = Path(args.output_dir).expanduser()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1) 영상 목록.
|
||||
try:
|
||||
videos = _list_videos(args.source, args.limit if args.limit > 0 else None)
|
||||
except Exception as e:
|
||||
_emit({"type": "error", "stage": "list", "message": str(e)})
|
||||
return 1
|
||||
|
||||
if not videos:
|
||||
_emit({"type": "error", "stage": "list", "message": "영상을 한 개도 찾지 못했습니다. URL 확인 필요."})
|
||||
return 1
|
||||
|
||||
_emit({"type": "start", "total": len(videos), "source": args.source, "output_dir": str(output_dir)})
|
||||
_trace("loop.begin", total=len(videos))
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
for i, v in enumerate(videos):
|
||||
vid = v["id"]
|
||||
title = v["title"]
|
||||
url = v["url"]
|
||||
_trace("loop.iter", index=i, video_id=vid)
|
||||
try:
|
||||
text = _fetch_transcript(vid, languages)
|
||||
safe_title = _safe_filename(title, max_len=80)
|
||||
filename = f"{safe_title}__{vid}.txt"
|
||||
target = output_dir / filename
|
||||
header = (
|
||||
f"제목: {title}\n"
|
||||
f"영상: {url}\n"
|
||||
f"비디오 ID: {vid}\n"
|
||||
f"언어 우선순위: {', '.join(languages)}\n"
|
||||
f"{'-' * 60}\n\n"
|
||||
)
|
||||
target.write_text(header + text, encoding="utf-8")
|
||||
ok += 1
|
||||
_emit({
|
||||
"type": "video", "index": i, "video_id": vid, "title": title,
|
||||
"status": "ok", "saved_to": str(target),
|
||||
})
|
||||
except KeyboardInterrupt:
|
||||
# Ctrl-C / abort 전파.
|
||||
raise
|
||||
except BaseException as e:
|
||||
# Exception 뿐 아니라 SystemExit(yt-dlp가 raise함)까지 잡는다. 어떤
|
||||
# 비정상 상황에서도 video 이벤트를 *반드시* emit해서 호출자가 영상을
|
||||
# "묵묵히 사라지지 않게" 한다.
|
||||
fail += 1
|
||||
error_msg = f"{type(e).__name__}: {e}"
|
||||
_emit({
|
||||
"type": "video", "index": i, "video_id": vid, "title": title,
|
||||
"status": "fail", "error": error_msg,
|
||||
})
|
||||
|
||||
_trace("loop.end", ok=ok, fail=fail)
|
||||
_emit({"type": "done", "ok": ok, "fail": fail, "output_dir": str(output_dir)})
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
_emit({"type": "error", "stage": "interrupt", "message": "사용자가 중단했습니다."})
|
||||
sys.exit(130)
|
||||
Reference in New Issue
Block a user