Update
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1778943957572,
|
||||
"createdAt": 1779073750556,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1778943957562,
|
||||
"createdAt": 1779073750553,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||
"createdAt": 1778943957561,
|
||||
"createdAt": 1779073750550,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "---\nid: stress_conflict_1778943957542\ndate: 2026-05-16T15:05:57.576Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (13ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (6ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (6ms)\n",
|
||||
"createdAt": 1778943957576,
|
||||
"result": "---\nid: stress_conflict_1779073750537\ndate: 2026-05-18T03:09:10.558Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (12ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (3ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (3ms)\n",
|
||||
"createdAt": 1779073750558,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+11
-11
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "stress_conflict_1778943957542",
|
||||
"missionId": "stress_conflict_1779073750537",
|
||||
"status": "completed",
|
||||
"startTime": "2026-05-16T15:05:57.542Z",
|
||||
"totalElapsedMs": 35,
|
||||
"startTime": "2026-05-18T03:09:10.537Z",
|
||||
"totalElapsedMs": 22,
|
||||
"results": {
|
||||
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
@@ -16,30 +16,30 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "planner",
|
||||
"durationMs": 13,
|
||||
"durationMs": 12,
|
||||
"message": "전략 수립 중...",
|
||||
"ts": "2026-05-16T15:05:57.555Z"
|
||||
"ts": "2026-05-18T03:09:10.549Z"
|
||||
},
|
||||
{
|
||||
"from": "planner",
|
||||
"to": "researcher",
|
||||
"durationMs": 6,
|
||||
"durationMs": 3,
|
||||
"message": "핵심 정보 수집 및 분석 중...",
|
||||
"ts": "2026-05-16T15:05:57.561Z"
|
||||
"ts": "2026-05-18T03:09:10.552Z"
|
||||
},
|
||||
{
|
||||
"from": "researcher",
|
||||
"to": "writer",
|
||||
"durationMs": 6,
|
||||
"durationMs": 3,
|
||||
"message": "최종 리포트 작성 및 편집 중...",
|
||||
"ts": "2026-05-16T15:05:57.567Z"
|
||||
"ts": "2026-05-18T03:09:10.555Z"
|
||||
},
|
||||
{
|
||||
"from": "writer",
|
||||
"to": "completed",
|
||||
"durationMs": 10,
|
||||
"durationMs": 4,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-05-16T15:05:57.577Z"
|
||||
"ts": "2026-05-18T03:09:10.559Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
@@ -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)
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"projectId": "connectai",
|
||||
"projectName": "ConnectAI",
|
||||
"projectRoot": "/Volumes/Data/project/Antigravity/ConnectAI",
|
||||
"recordRoot": "/Volumes/Data/project/Antigravity/ConnectAI/docs/records/ConnectAI",
|
||||
"projectName": "connectai",
|
||||
"projectRoot": "E:\\Wiki\\connectai",
|
||||
"recordRoot": "E:\\Wiki\\connectai\\docs\\records\\connectai",
|
||||
"description": "Auto-created by Project Architecture activation.",
|
||||
"corePurpose": "",
|
||||
"detailLevel": "standard",
|
||||
"createdAt": "2026-05-13T13:09:33.788Z",
|
||||
"updatedAt": "2026-05-17T14:50:44.364Z"
|
||||
"createdAt": "2026-05-14T00:57:32.245Z",
|
||||
"updatedAt": "2026-05-18T03:11:17.574Z"
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<button class="hdr-menu-item toggle-item" id="brainTraceDebugBtn" data-tooltip="근거 추적의 원본 JSON 표시 (개발자용)">근거 추적 JSON 보기</button>
|
||||
<button class="hdr-menu-item" id="saveWikiRawBtn" data-tooltip="현재 답변의 원본 마크다운을 두뇌(지식)에 저장">원본 답변을 두뇌에 저장</button>
|
||||
<button class="hdr-menu-item" id="brainBtn" data-tooltip="두뇌(지식) 폴더 변경사항을 git commit + push">두뇌 동기화</button>
|
||||
<button class="hdr-menu-item" id="ytExtractBtn" data-tooltip="채널/플레이리스트/단일 영상 URL에서 자막을 일괄 추출해 지정 폴더에 저장">▶ YouTube 자막 추출</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="icon-btn" id="historyBtn" data-tooltip="이전 대화 기록 보기">기록</button>
|
||||
|
||||
@@ -1661,6 +1661,9 @@
|
||||
const syncBrain = () => { Sound.play(550, 'sine', 0.1); vscode.postMessage({ type: 'syncBrain' }); };
|
||||
document.getElementById('brainBtn').onclick = syncBrain;
|
||||
saveWikiRawBtn.onclick = () => vscode.postMessage({ type: 'saveWikiRaw' });
|
||||
// YouTube 자막 추출 — 도구 메뉴 진입. backend가 wizard(URL/폴더/언어/limit)를 띄움.
|
||||
const ytExtractBtn = document.getElementById('ytExtractBtn');
|
||||
if (ytExtractBtn) ytExtractBtn.onclick = () => vscode.postMessage({ type: 'extractYoutubeTranscripts' });
|
||||
addBrainBtn.onclick = () => vscode.postMessage({ type: 'addBrain' });
|
||||
editBrainBtn.onclick = () => {
|
||||
if (!brainSel.value || brainSel.value === 'new') return;
|
||||
|
||||
+5
-1
@@ -2,7 +2,7 @@
|
||||
"name": "astra",
|
||||
"displayName": "Astra",
|
||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||
"version": "2.2.19",
|
||||
"version": "2.2.26",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -135,6 +135,10 @@
|
||||
"command": "g1nation.company.pixelOffice.open",
|
||||
"title": "Astra: Open Pixel Office (Full Screen)"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.youtube.extractTranscripts",
|
||||
"title": "Astra: Extract YouTube Transcripts"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.calendar.connect",
|
||||
"title": "Astra: Google Calendar (iCal) 연결 📅"
|
||||
|
||||
@@ -664,6 +664,12 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// 같은 pixelOfficeUpdate 메시지 스트림을 공유하므로 백엔드 변경 최소.
|
||||
provider?.openPixelOfficePanel();
|
||||
}),
|
||||
// YouTube 자막 추출 — 채널/플레이리스트/단일 영상 URL → 사용자 폴더에 저장.
|
||||
// Command Palette + 사이드바 버튼 + 채팅 키워드 셋 다 같은 wizard로 라우팅.
|
||||
vscode.commands.registerCommand('g1nation.youtube.extractTranscripts', async (arg?: { url?: string }) => {
|
||||
const { runExtractWizard } = await import('./features/youtube/extractCommand');
|
||||
await runExtractWizard(context.extensionUri, { initialUrl: arg?.url });
|
||||
}),
|
||||
// Google Calendar (iCal 읽기 전용) — 셋업 / 재연결 / 해제 / 즉시 새로고침.
|
||||
vscode.commands.registerCommand('g1nation.calendar.connect', async () => {
|
||||
await runConnectGoogleCalendarIcal(context);
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* YouTube 자막 추출 — Command Palette 진입점.
|
||||
*
|
||||
* 사용자 입력 흐름:
|
||||
* 1. URL (채널/플레이리스트/단일 영상)
|
||||
* 2. 자막 저장 폴더 (OS 다이얼로그)
|
||||
* 3. 자막 언어 우선순위 (기본 ko,en — Enter로 통과 가능)
|
||||
* 4. 최대 영상 수 (기본 0 = 제한 없음, 대형 채널 보호용 limit)
|
||||
* 5. Progress notification — 영상 단위 진행률 표시
|
||||
* 6. 완료 시 결과 폴더 자동 open
|
||||
*
|
||||
* 입력 dialog 도중 사용자가 Esc/Cancel하면 즉시 중단. 진행 중 notification의
|
||||
* Cancel 버튼은 AbortController로 Python 프로세스 SIGTERM.
|
||||
*
|
||||
* `chatHandlers`에서도 키워드 감지 시 같은 흐름을 호출 — `runExtractWizard()`가 공용 entry.
|
||||
*/
|
||||
import * as vscode from 'vscode';
|
||||
import { extractTranscripts, installPythonPackages, type ExtractEvent } from './transcriptService';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/** 채팅 키워드 감지에서 전달한 *사전 채워진 URL*이 있으면 받는다. */
|
||||
export interface ExtractWizardOptions {
|
||||
/** 사용자가 채팅에 적은 URL이 있으면 input box의 기본값으로 사용. */
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
/** YouTube URL 검증 — 채널/플레이리스트/단일 영상 모두 통과. */
|
||||
function _isYoutubeUrl(s: string): boolean {
|
||||
if (!s) return false;
|
||||
try {
|
||||
const u = new URL(s.trim());
|
||||
const h = u.hostname.toLowerCase();
|
||||
return /(?:^|\.)(youtube\.com|youtu\.be)$/.test(h);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runExtractWizard(
|
||||
extensionUri: vscode.Uri,
|
||||
opts: ExtractWizardOptions = {},
|
||||
): Promise<void> {
|
||||
// 1) URL
|
||||
const url = await vscode.window.showInputBox({
|
||||
title: 'YouTube 자막 추출 — 1/4',
|
||||
prompt: '채널 / 플레이리스트 / 단일 영상 URL을 입력하세요',
|
||||
value: opts.initialUrl ?? '',
|
||||
placeHolder: 'https://www.youtube.com/@channel · https://www.youtube.com/watch?v=...',
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (v) => {
|
||||
if (!v?.trim()) return 'URL을 입력하세요';
|
||||
if (!_isYoutubeUrl(v)) return 'YouTube URL이 아닙니다';
|
||||
return null;
|
||||
},
|
||||
});
|
||||
if (!url) return; // Esc / 취소
|
||||
|
||||
// 2) 폴더
|
||||
const folder = await vscode.window.showOpenDialog({
|
||||
title: 'YouTube 자막 추출 — 2/4 · 저장할 폴더 선택',
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
canSelectMany: false,
|
||||
openLabel: '이 폴더에 저장',
|
||||
});
|
||||
if (!folder || folder.length === 0) return;
|
||||
const outputDir = folder[0].fsPath;
|
||||
|
||||
// 3) 언어 우선순위
|
||||
const langInput = await vscode.window.showInputBox({
|
||||
title: 'YouTube 자막 추출 — 3/4',
|
||||
prompt: '자막 언어 우선순위 (콤마 구분). Enter로 기본값(ko,en) 사용',
|
||||
value: 'ko,en',
|
||||
placeHolder: 'ko,en,ja',
|
||||
ignoreFocusOut: true,
|
||||
});
|
||||
if (langInput === undefined) return; // 취소 — 빈 문자열은 default 적용해도 OK
|
||||
const languages = (langInput.trim() || 'ko,en')
|
||||
.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
||||
|
||||
// 4) 영상 수 제한
|
||||
const limitInput = await vscode.window.showInputBox({
|
||||
title: 'YouTube 자막 추출 — 4/4',
|
||||
prompt: '최대 영상 수 (대형 채널 안전 cap). 0 = 제한 없음',
|
||||
value: '0',
|
||||
placeHolder: '0',
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (v) => {
|
||||
const n = parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || n < 0) return '0 이상의 숫자';
|
||||
return null;
|
||||
},
|
||||
});
|
||||
if (limitInput === undefined) return;
|
||||
const limit = parseInt(limitInput.trim() || '0', 10);
|
||||
|
||||
// 5) 추출 실행 — vscode progress notification 안에서.
|
||||
const abort = new AbortController();
|
||||
let started = false;
|
||||
let totalVideos = 0;
|
||||
let savedOk = 0;
|
||||
let savedFail = 0;
|
||||
let lastError: { stage: string; message: string; installCommand?: string } | undefined;
|
||||
let stderrTail: string | undefined;
|
||||
let exitCode = 0;
|
||||
// 영상별 실패 사유 누적 — 완료 알림에 첫 케이스를 노출하고 사용자가 디버깅
|
||||
// 할 수 있게 한다.
|
||||
const failures: Array<{ title: string; videoId: string; error: string }> = [];
|
||||
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: 'YouTube 자막 추출 중',
|
||||
cancellable: true,
|
||||
},
|
||||
async (progress, token) => {
|
||||
token.onCancellationRequested(() => abort.abort());
|
||||
progress.report({ message: '영상 목록 수집…' });
|
||||
|
||||
const result = await extractTranscripts(extensionUri, {
|
||||
source: url,
|
||||
outputDir,
|
||||
languages,
|
||||
limit,
|
||||
signal: abort.signal,
|
||||
onEvent: (ev: ExtractEvent) => {
|
||||
if (ev.type === 'start') {
|
||||
started = true;
|
||||
totalVideos = ev.total;
|
||||
progress.report({
|
||||
message: `총 ${ev.total}개 영상 · 자막 추출 시작`,
|
||||
});
|
||||
} else if (ev.type === 'video') {
|
||||
if (ev.status === 'ok') savedOk++;
|
||||
else {
|
||||
savedFail++;
|
||||
failures.push({
|
||||
title: ev.title || ev.video_id,
|
||||
videoId: ev.video_id,
|
||||
error: ev.error || '(이유 불명)',
|
||||
});
|
||||
}
|
||||
const done = savedOk + savedFail;
|
||||
const pct = totalVideos > 0 ? Math.round((done / totalVideos) * 100) : 0;
|
||||
const verb = ev.status === 'ok' ? '✓' : '✗';
|
||||
progress.report({
|
||||
increment: totalVideos > 0 ? 100 / totalVideos : 0,
|
||||
message: `${done}/${totalVideos} ${pct}% · ${verb} ${ev.title.slice(0, 40)}`,
|
||||
});
|
||||
} else if (ev.type === 'error') {
|
||||
lastError = {
|
||||
stage: ev.stage,
|
||||
message: ev.message,
|
||||
installCommand: ev.install_command,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
if (result.error) lastError = result.error;
|
||||
stderrTail = result.stderrTail;
|
||||
exitCode = result.exitCode;
|
||||
},
|
||||
);
|
||||
|
||||
// 6) 결과 안내.
|
||||
if (lastError) {
|
||||
// deps 누락이면 *자동 설치* 옵션을 우선 제시. 사용자가 동의하면 우리가
|
||||
// spawn한 그 Python으로 `python -m pip install`을 돌려 환경 불일치까지
|
||||
// 같이 해결. 성공 시 자동 재시도.
|
||||
if (lastError.stage === 'deps') {
|
||||
// installCommand 예: "pip install yt-dlp youtube-transcript-api"
|
||||
// → 패키지 이름들만 추출.
|
||||
const pkgNames = (lastError.installCommand ?? '')
|
||||
.replace(/^pip\s+install\s+/i, '')
|
||||
.split(/\s+/)
|
||||
.filter((s) => s.length > 0);
|
||||
const action = await vscode.window.showErrorMessage(
|
||||
`필수 Python 패키지가 없습니다 — ${pkgNames.join(', ')}`,
|
||||
{ modal: false },
|
||||
'자동 설치',
|
||||
'설치 명령 복사',
|
||||
);
|
||||
if (action === '자동 설치') {
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: `pip 패키지 설치 중 — ${pkgNames.join(', ')}`,
|
||||
cancellable: false,
|
||||
},
|
||||
async (prog) => {
|
||||
let lastLine = '';
|
||||
const installRes = await installPythonPackages(pkgNames, {
|
||||
onLine: (line) => {
|
||||
lastLine = line;
|
||||
// 너무 시끄럽지 않게 마지막 의미 있는 줄만 표시.
|
||||
if (line.length < 120) prog.report({ message: line });
|
||||
},
|
||||
});
|
||||
if (!installRes.ok) {
|
||||
vscode.window.showErrorMessage(
|
||||
`자동 설치 실패 (exit ${installRes.exitCode}). ` +
|
||||
`터미널에서 직접: pip install ${pkgNames.join(' ')}\n` +
|
||||
(installRes.stderrTail ? `\n에러: ${installRes.stderrTail.slice(-300)}` : ''),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 설치 성공 → 자동 재시도. wizard를 처음부터 다시 돌리지 않고
|
||||
// 사용자가 방금 입력한 URL/폴더/언어/limit 그대로 재실행.
|
||||
const retry = await vscode.window.showInformationMessage(
|
||||
'설치 완료. 같은 설정으로 자막 추출을 다시 시작할까요?',
|
||||
'재시도', '나중에',
|
||||
);
|
||||
if (retry === '재시도') {
|
||||
await runExtractWizard(extensionUri, { initialUrl: url });
|
||||
}
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action === '설치 명령 복사' && lastError.installCommand) {
|
||||
await vscode.env.clipboard.writeText(lastError.installCommand);
|
||||
vscode.window.showInformationMessage(`클립보드에 복사됨: ${lastError.installCommand}`);
|
||||
}
|
||||
logError('youtube.extract: deps missing.', { packages: pkgNames });
|
||||
return;
|
||||
}
|
||||
// deps 외 일반 에러.
|
||||
const action = await vscode.window.showErrorMessage(
|
||||
`YouTube 자막 추출 실패 — ${lastError.message}`,
|
||||
{ modal: false },
|
||||
'폴더 열기',
|
||||
);
|
||||
if (action === '폴더 열기' && started) {
|
||||
await vscode.env.openExternal(vscode.Uri.file(outputDir));
|
||||
}
|
||||
logError('youtube.extract: finished with error.', { error: lastError });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
// Python 스크립트가 start 이벤트조차 못 emit — 영상 목록 단계에서 실패했거나
|
||||
// 인터프리터가 즉시 죽음. stderr가 있으면 함께 노출해 사용자가 traceback 확인.
|
||||
const detail = stderrTail ? `\n\nPython stderr:\n${stderrTail.slice(-800)}` : '';
|
||||
const choice = await vscode.window.showWarningMessage(
|
||||
`영상을 한 개도 못 가져왔습니다. URL이 정확한지, 채널/플레이리스트가 공개돼 있는지 확인하세요. (exit=${exitCode})${detail}`,
|
||||
{ modal: false }, '자세히 보기',
|
||||
);
|
||||
if (choice === '자세히 보기' && stderrTail) {
|
||||
await _openDiagnosticsDoc({ url, outputDir, totalVideos, exitCode, stderrTail, failures });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 매우 의심스러운 케이스: Python이 start 이벤트는 보냈는데 video 이벤트가 0건 +
|
||||
// 실패 카운트도 0. 보통 정상 흐름이라면 video 이벤트가 적어도 한 번은 와야 한다.
|
||||
// 즉 Python이 video 이벤트 emit 전에 *조용히 죽음*. stderr 확인이 결정적.
|
||||
if (savedOk === 0 && savedFail === 0) {
|
||||
const detail = stderrTail ? `\n\nPython stderr:\n${stderrTail.slice(-800)}` : '';
|
||||
const choice = await vscode.window.showWarningMessage(
|
||||
`영상 ${totalVideos}개를 발견했지만 자막 추출 사이클이 시작 전 종료되었습니다. ` +
|
||||
`Python이 비정상 종료되었을 가능성이 있습니다. (exit=${exitCode})${detail}`,
|
||||
{ modal: false }, '자세히 보기', '폴더 열기',
|
||||
);
|
||||
if (choice === '자세히 보기') {
|
||||
await _openDiagnosticsDoc({ url, outputDir, totalVideos, exitCode, stderrTail, failures });
|
||||
}
|
||||
if (choice === '폴더 열기') {
|
||||
await vscode.env.openExternal(vscode.Uri.file(outputDir));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 실패 영상이 있으면 첫 케이스 사유를 알림에 직접 노출 + "자세히 보기" 버튼.
|
||||
// 사용자가 정확히 *왜* 실패했는지 추측 안 하고 바로 보게.
|
||||
const summary = `자막 추출 완료 — ${savedOk}개 성공${savedFail > 0 ? ` / ${savedFail}개 실패` : ''}`;
|
||||
if (savedFail > 0) {
|
||||
const first = failures[0];
|
||||
const hint = _explainFailure(first?.error || '');
|
||||
const detailBody = failures
|
||||
.map((f, i) => `[${i + 1}] ${f.title} (${f.videoId})\n → ${f.error}`)
|
||||
.join('\n');
|
||||
logInfo('youtube.extract: failures.', { count: savedFail, sample: detailBody.slice(0, 400) });
|
||||
const msg = `${summary}\n\n첫 실패: "${first?.title}" — ${first?.error}${hint ? `\n→ ${hint}` : ''}`;
|
||||
const choice = await vscode.window.showWarningMessage(
|
||||
msg, { modal: false }, '폴더 열기', '자세히 보기',
|
||||
);
|
||||
if (choice === '폴더 열기') {
|
||||
await vscode.env.openExternal(vscode.Uri.file(outputDir));
|
||||
}
|
||||
if (choice === '자세히 보기') {
|
||||
await _openDiagnosticsDoc({ url, outputDir, totalVideos, exitCode, stderrTail, failures });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const choice = await vscode.window.showInformationMessage(summary, '폴더 열기');
|
||||
if (choice === '폴더 열기') {
|
||||
await vscode.env.openExternal(vscode.Uri.file(outputDir));
|
||||
}
|
||||
logInfo('youtube.extract: done.', { ok: savedOk, fail: savedFail, outputDir });
|
||||
}
|
||||
|
||||
/** 진단 정보를 새 탭으로 열어 사용자가 그대로 복사·공유 가능하게. */
|
||||
async function _openDiagnosticsDoc(info: {
|
||||
url: string;
|
||||
outputDir: string;
|
||||
totalVideos: number;
|
||||
exitCode: number;
|
||||
stderrTail?: string;
|
||||
failures: Array<{ title: string; videoId: string; error: string }>;
|
||||
}): Promise<void> {
|
||||
const parts: string[] = [];
|
||||
parts.push('# YouTube 자막 추출 진단');
|
||||
parts.push('');
|
||||
parts.push(`- URL: ${info.url}`);
|
||||
parts.push(`- 저장 폴더: ${info.outputDir}`);
|
||||
parts.push(`- 영상 수: ${info.totalVideos}`);
|
||||
parts.push(`- Python exitCode: ${info.exitCode}`);
|
||||
parts.push('');
|
||||
if (info.stderrTail) {
|
||||
parts.push('## Python stderr (마지막 1500자)');
|
||||
parts.push('```');
|
||||
parts.push(info.stderrTail);
|
||||
parts.push('```');
|
||||
parts.push('');
|
||||
}
|
||||
if (info.failures.length > 0) {
|
||||
parts.push(`## 실패 영상 ${info.failures.length}건`);
|
||||
for (let i = 0; i < info.failures.length; i++) {
|
||||
const f = info.failures[i];
|
||||
parts.push(`### [${i + 1}] ${f.title} (${f.videoId})`);
|
||||
parts.push('```');
|
||||
parts.push(f.error);
|
||||
parts.push('```');
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
parts.push('## 흔한 원인');
|
||||
parts.push('- **영상 0개**: URL이 채널 홈/playlist이지만 영상이 없거나, `@handle` 대신 `/channel/UC...` 또는 `/c/<name>` 형태가 필요');
|
||||
parts.push('- **start 후 즉시 종료**: yt-dlp 또는 youtube-transcript-api가 *import* 단계에서 segfault 또는 환경 의존 문제');
|
||||
parts.push('- **자막 0개 감지**: 1차(transcript-api) + 2차(yt-dlp) 모두 자막 못 받음. 위 stderr/실패 메시지로 어느 단계인지 확인');
|
||||
parts.push('');
|
||||
parts.push('## 권장 조치');
|
||||
parts.push('1. 패키지 최신화: `pip install --upgrade yt-dlp youtube-transcript-api`');
|
||||
parts.push('2. URL을 단일 영상(`https://www.youtube.com/watch?v=...`)으로 테스트');
|
||||
parts.push('3. 그래도 실패하면 위 stderr 내용을 그대로 알려 주세요');
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
content: parts.join('\n'),
|
||||
language: 'markdown',
|
||||
});
|
||||
await vscode.window.showTextDocument(doc, { preview: false });
|
||||
}
|
||||
|
||||
/** 흔한 실패 패턴을 사용자 친화 안내로 번역. 못 알아보면 '' 반환. */
|
||||
function _explainFailure(error: string): string {
|
||||
const s = (error || '').toLowerCase();
|
||||
if (s.includes('subtitles are disabled') || s.includes('transcriptsdisabled')) {
|
||||
return '이 영상에는 자막이 없거나 게시자가 자막을 꺼뒀습니다.';
|
||||
}
|
||||
if (s.includes('no transcript') || s.includes('notranscriptfound')) {
|
||||
return '요청한 언어의 자막이 없습니다. 언어 옵션을 늘려 보세요(예: ko,en,ja).';
|
||||
}
|
||||
if (s.includes('age') && s.includes('confirm')) {
|
||||
return '연령 제한 영상은 자막 접근 불가입니다.';
|
||||
}
|
||||
if (s.includes('video unavailable')) {
|
||||
return '비공개·삭제·지역 제한 영상입니다.';
|
||||
}
|
||||
if (s.includes('no element found') || s.includes('expecting value') || s.includes('parsererror')) {
|
||||
return 'YouTube API 변경 — 패키지 업데이트 필요: pip install --upgrade yt-dlp youtube-transcript-api';
|
||||
}
|
||||
if (s.includes('too many requests') || s.includes('429')) {
|
||||
return 'YouTube 요청 한도에 걸렸습니다. 잠시 후 다시 시도하세요.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* YouTube Transcript Service — Astra extension.
|
||||
*
|
||||
* Python 동봉 스크립트(`assets/scripts/youtube_transcript.py`)를 spawn해서 채널/
|
||||
* 플레이리스트/단일 영상 URL의 자막을 사용자 지정 폴더에 일괄 저장한다. Python
|
||||
* 스크립트의 stdout은 JSON 한 줄 단위 stream — 이 모듈이 line 단위로 파싱해서
|
||||
* progress 콜백으로 UI에 전달.
|
||||
*
|
||||
* 호출자(`extension.ts`의 명령 핸들러 / `chatHandlers`의 키워드 라우터)는
|
||||
* `extractTranscripts(opts)` 한 함수만 호출. 진행률 / 완료 / 에러는 옵션 콜백.
|
||||
*
|
||||
* 의존성:
|
||||
* - 사용자 환경에 Python 3 설치 필요
|
||||
* - 첫 실행 시 `yt-dlp`, `youtube-transcript-api` pip 패키지 필요 (Python 스크립트가
|
||||
* 누락 시 친절한 메시지 + install 명령을 stdout으로 emit)
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
export type ExtractEvent =
|
||||
| { type: 'start'; total: number; source: string; output_dir: string }
|
||||
| { type: 'video'; index: number; video_id: string; title: string; status: 'ok' | 'fail'; saved_to?: string; error?: string }
|
||||
| { type: 'done'; ok: number; fail: number; output_dir: string }
|
||||
| { type: 'error'; stage: string; message: string; install_command?: string };
|
||||
|
||||
export interface ExtractOptions {
|
||||
/** 채널 / 플레이리스트 / 단일 영상 URL. */
|
||||
source: string;
|
||||
/** 자막 저장 폴더 (반드시 절대 경로). */
|
||||
outputDir: string;
|
||||
/** 자막 언어 우선순위 (예: ['ko', 'en']). 기본 ['ko', 'en']. */
|
||||
languages?: string[];
|
||||
/** 추출할 최대 영상 수 (0 = 제한 없음). */
|
||||
limit?: number;
|
||||
/** Python 인터프리터 — 미지정 시 `python` → `python3` 순으로 자동 fallback. */
|
||||
pythonPath?: string;
|
||||
/** 진행 이벤트 콜백. webview/notification progress 업데이트용. */
|
||||
onEvent?: (event: ExtractEvent) => void;
|
||||
/** AbortSignal — 사용자가 작업 중단 시 호출자가 fire. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ExtractResult {
|
||||
/** Python 프로세스 종료 코드. 0=성공, 그 외=실패. */
|
||||
exitCode: number;
|
||||
/** 성공 영상 수. */
|
||||
ok: number;
|
||||
/** 실패 영상 수. */
|
||||
fail: number;
|
||||
/** 저장 폴더 (정규화된 절대 경로). */
|
||||
outputDir: string;
|
||||
/** 마지막 error event (있으면). 의존성 누락 등을 호출자가 식별. */
|
||||
error?: { stage: string; message: string; installCommand?: string };
|
||||
/** start 이벤트가 한 번이라도 들어왔는지. false면 Python이 영상 목록도 못 만듦. */
|
||||
startEventReceived: boolean;
|
||||
/** Python이 보고한 영상 총개수 (start.total). 0이면 URL에서 영상 못 찾음. */
|
||||
totalVideos: number;
|
||||
/** Python stderr 마지막 ~1500자. 비정상 종료 시 traceback이 여기에. */
|
||||
stderrTail?: string;
|
||||
}
|
||||
|
||||
/** Python 스크립트 경로 결정 — extension 번들 안 assets/scripts/. */
|
||||
function _scriptPath(extensionUri: vscode.Uri): string {
|
||||
return path.join(extensionUri.fsPath, 'assets', 'scripts', 'youtube_transcript.py');
|
||||
}
|
||||
|
||||
/**
|
||||
* 누락된 pip 패키지를 *우리가 spawn한 그 Python*에 설치. 사용자가 pip install
|
||||
* 을 다른 Python에 했거나, 가상환경/conda를 쓰는 경우 환경 불일치를 자동 해결.
|
||||
*
|
||||
* `python -m pip install <pkg> ...` 형태로 호출 — 시스템 pip이 아니라 *이* Python의
|
||||
* pip을 강제 사용. 출력은 onLine 콜백으로 전달해 진행 상황 표시 가능.
|
||||
*/
|
||||
export async function installPythonPackages(
|
||||
packages: string[],
|
||||
opts: { pythonPath?: string; onLine?: (line: string) => void } = {},
|
||||
): Promise<{ ok: boolean; exitCode: number; stderrTail?: string }> {
|
||||
const python = await _resolvePython(opts.pythonPath);
|
||||
if (!python) {
|
||||
return { ok: false, exitCode: -1, stderrTail: 'Python 인터프리터를 찾을 수 없습니다.' };
|
||||
}
|
||||
if (!packages || packages.length === 0) {
|
||||
return { ok: true, exitCode: 0 };
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let proc: ReturnType<typeof spawn>;
|
||||
const args = ['-m', 'pip', 'install', '--upgrade', '--disable-pip-version-check', ...packages];
|
||||
try {
|
||||
const env = { ...process.env, PYTHONIOENCODING: 'utf-8' };
|
||||
proc = spawn(python, args, { shell: false, windowsHide: true, env });
|
||||
} catch (e: any) {
|
||||
resolve({ ok: false, exitCode: -1, stderrTail: e?.message ?? String(e) });
|
||||
return;
|
||||
}
|
||||
let stderrTail = '';
|
||||
const onChunk = (b: any) => {
|
||||
const s = b.toString();
|
||||
stderrTail += s;
|
||||
if (stderrTail.length > 2000) stderrTail = stderrTail.slice(-2000);
|
||||
// 라인 단위로 잘라 진행 보고.
|
||||
for (const line of s.split(/\r?\n/)) {
|
||||
if (line.trim()) opts.onLine?.(line);
|
||||
}
|
||||
};
|
||||
proc.stdout?.setEncoding('utf-8');
|
||||
proc.stderr?.setEncoding('utf-8');
|
||||
proc.stdout?.on('data', onChunk);
|
||||
proc.stderr?.on('data', onChunk);
|
||||
proc.on('error', (e: any) => {
|
||||
resolve({ ok: false, exitCode: -1, stderrTail: e?.message ?? String(e) });
|
||||
});
|
||||
proc.on('close', (code) => {
|
||||
resolve({ ok: code === 0, exitCode: code ?? -1, stderrTail });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Python 인터프리터 후보를 *순서대로* 시도해 동작하는 첫 번째 반환. spawn 실패면
|
||||
* 다음 후보. 모두 실패하면 undefined — 호출자가 사용자에게 Python 설치 안내.
|
||||
*/
|
||||
async function _resolvePython(explicit?: string): Promise<string | undefined> {
|
||||
const candidates = explicit
|
||||
? [explicit]
|
||||
: (process.platform === 'win32' ? ['python', 'py', 'python3'] : ['python3', 'python']);
|
||||
for (const cmd of candidates) {
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
try {
|
||||
const p = spawn(cmd, ['--version'], { shell: false, windowsHide: true });
|
||||
let done = false;
|
||||
p.on('error', () => { if (!done) { done = true; resolve(false); } });
|
||||
p.on('close', (code) => { if (!done) { done = true; resolve(code === 0); } });
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
if (ok) return cmd;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream JSON line parser — Python stdout이 한 줄에 한 JSON event. 부분 청크가
|
||||
* 경계에서 잘려도 다음 청크와 합쳐 한 줄이 완성되면 파싱. 잘못된 JSON은 무시
|
||||
* (Python 스크립트 외 stderr 또는 stdout 디버그 출력 보호).
|
||||
*/
|
||||
function _makeLineParser(onEvent: (ev: ExtractEvent) => void): (chunk: string) => void {
|
||||
let buffer = '';
|
||||
return (chunk: string) => {
|
||||
buffer += chunk;
|
||||
let nl: number;
|
||||
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, nl).trim();
|
||||
buffer = buffer.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line) as ExtractEvent;
|
||||
onEvent(obj);
|
||||
} catch {
|
||||
// 파이썬이 디버그 print를 했거나 stderr가 잘못 섞인 경우 — skip.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 엔트리. Python을 spawn해 자막 추출을 수행하고 진행 이벤트를 콜백으로
|
||||
* 흘려준다. Python 인터프리터 없거나 의존 패키지 없거나 등 *환경 문제*는
|
||||
* `result.error`로 명시되어 반환 — throw 하지 않음.
|
||||
*/
|
||||
export async function extractTranscripts(
|
||||
extensionUri: vscode.Uri,
|
||||
opts: ExtractOptions,
|
||||
): Promise<ExtractResult> {
|
||||
const scriptPath = _scriptPath(extensionUri);
|
||||
const python = await _resolvePython(opts.pythonPath);
|
||||
if (!python) {
|
||||
const err = {
|
||||
stage: 'python',
|
||||
message: 'Python 3이 설치돼 있지 않거나 PATH에 없습니다. https://www.python.org 에서 설치 후 다시 시도하세요.',
|
||||
};
|
||||
opts.onEvent?.({ type: 'error', ...err });
|
||||
return {
|
||||
exitCode: -1, ok: 0, fail: 0, outputDir: opts.outputDir, error: err,
|
||||
startEventReceived: false, totalVideos: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const args = [
|
||||
scriptPath,
|
||||
'--source', opts.source,
|
||||
'--output-dir', opts.outputDir,
|
||||
'--lang', (opts.languages && opts.languages.length > 0 ? opts.languages.join(',') : 'ko,en'),
|
||||
];
|
||||
if (opts.limit && opts.limit > 0) args.push('--limit', String(opts.limit));
|
||||
|
||||
logInfo('youtube.extract: spawning python.', { python, source: opts.source, outputDir: opts.outputDir });
|
||||
|
||||
let ok = 0, fail = 0;
|
||||
let lastError: ExtractResult['error'];
|
||||
let startEventReceived = false;
|
||||
let totalVideos = 0;
|
||||
const parseLine = _makeLineParser((ev) => {
|
||||
if (ev.type === 'start') {
|
||||
startEventReceived = true;
|
||||
totalVideos = ev.total ?? 0;
|
||||
} else if (ev.type === 'video') {
|
||||
if (ev.status === 'ok') ok++;
|
||||
else fail++;
|
||||
} else if (ev.type === 'done') {
|
||||
ok = ev.ok; fail = ev.fail;
|
||||
} else if (ev.type === 'error') {
|
||||
lastError = {
|
||||
stage: ev.stage,
|
||||
message: ev.message,
|
||||
installCommand: ev.install_command,
|
||||
};
|
||||
}
|
||||
opts.onEvent?.(ev);
|
||||
});
|
||||
|
||||
return new Promise<ExtractResult>((resolve) => {
|
||||
let proc: ReturnType<typeof spawn>;
|
||||
try {
|
||||
// PYTHONIOENCODING=utf-8 — Windows cp949 default가 한글 JSON을 깨뜨리는
|
||||
// 문제를 막는다. Python 3.7+의 reconfigure가 같은 일을 하지만 매우 오래된
|
||||
// Python을 위한 fallback 안전망.
|
||||
const env = { ...process.env, PYTHONIOENCODING: 'utf-8' };
|
||||
proc = spawn(python, args, { shell: false, windowsHide: true, env });
|
||||
} catch (e: any) {
|
||||
const err = { stage: 'spawn', message: e?.message ?? String(e) };
|
||||
opts.onEvent?.({ type: 'error', ...err });
|
||||
resolve({
|
||||
exitCode: -1, ok: 0, fail: 0, outputDir: opts.outputDir, error: err,
|
||||
startEventReceived: false, totalVideos: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// AbortSignal 연결.
|
||||
const onAbort = () => {
|
||||
try { proc.kill('SIGTERM'); } catch { /* noop */ }
|
||||
};
|
||||
opts.signal?.addEventListener('abort', onAbort);
|
||||
proc.stdout?.setEncoding('utf-8');
|
||||
proc.stderr?.setEncoding('utf-8');
|
||||
proc.stdout?.on('data', (chunk: string) => parseLine(chunk));
|
||||
// stderr는 Python 트레이스백 등이 올 수 있음 — 로그만 남기고 UI엔 안 띄움.
|
||||
let stderrBuf = '';
|
||||
proc.stderr?.on('data', (chunk: string) => { stderrBuf += chunk; });
|
||||
proc.on('error', (e: any) => {
|
||||
logError('youtube.extract: spawn error.', { error: e?.message ?? String(e) });
|
||||
const err = { stage: 'spawn', message: e?.message ?? String(e) };
|
||||
opts.onEvent?.({ type: 'error', ...err });
|
||||
resolve({
|
||||
exitCode: -1, ok, fail, outputDir: opts.outputDir, error: err,
|
||||
startEventReceived, totalVideos,
|
||||
stderrTail: stderrBuf ? stderrBuf.slice(-1500) : undefined,
|
||||
});
|
||||
});
|
||||
proc.on('close', (code) => {
|
||||
opts.signal?.removeEventListener('abort', onAbort);
|
||||
if (stderrBuf.trim()) {
|
||||
logInfo('youtube.extract: stderr tail.', { tail: stderrBuf.slice(-500) });
|
||||
}
|
||||
resolve({
|
||||
exitCode: code ?? -1,
|
||||
ok, fail,
|
||||
outputDir: opts.outputDir,
|
||||
error: lastError,
|
||||
startEventReceived, totalVideos,
|
||||
stderrTail: stderrBuf ? stderrBuf.slice(-1500) : undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,26 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
case 'promptWithFile':
|
||||
provider._lmStudio?.activity.bump();
|
||||
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
|
||||
// ── YouTube 자막 추출 키워드 감지 ──
|
||||
// "이 채널/영상 자막 추출"·"유튜브 스크립트 다운로드" 같은 발화에
|
||||
// YouTube URL이 같이 있으면 자동으로 추출 wizard 진입. 회사 모드
|
||||
// 분기 *전*에 둬서 일반 채팅 / 회사 모드 둘 다에서 작동. 키워드만
|
||||
// 있고 URL이 없으면 URL을 비워둔 wizard로 — 사용자가 다이얼로그에
|
||||
// 직접 입력.
|
||||
if (typeof data.value === 'string') {
|
||||
const text = data.value;
|
||||
const intent = /(?:유튜브|youtube|yt)/i.test(text)
|
||||
&& /(?:자막|스크립트|transcript|caption|subtitle|추출|다운로드|받아|모아|수집|가져)/i.test(text);
|
||||
if (intent) {
|
||||
const urlMatch = text.match(/https?:\/\/(?:www\.|m\.)?(?:youtube\.com|youtu\.be)\/\S+/i);
|
||||
const userMsg = urlMatch
|
||||
? `🎬 YouTube 자막 추출 wizard를 엽니다. (감지된 URL: ${urlMatch[0]})`
|
||||
: '🎬 YouTube 자막 추출 wizard를 엽니다. URL을 직접 입력하세요.';
|
||||
provider._view?.webview.postMessage({ type: 'addMessage', role: 'assistant', value: userMsg });
|
||||
await vscode.commands.executeCommand('g1nation.youtube.extractTranscripts', { url: urlMatch?.[0] });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// ── 1인 기업 모드 우선 분기 ──
|
||||
// 회사 모드일 땐 들어온 메시지를 intent classifier에 한 번 통과시켜
|
||||
// (a) 잡담/질문/짧은 응답 → 일반 채팅 경로, (b) 후속 대화 → 일반 채팅
|
||||
@@ -261,6 +281,12 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
case 'addMessage':
|
||||
provider._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale });
|
||||
return true;
|
||||
case 'extractYoutubeTranscripts':
|
||||
// 사이드바 도구 메뉴의 ▶ YouTube 자막 추출 버튼 → wizard 진입.
|
||||
// 명령 자체는 extension.ts에 등록돼 있어 wizard가 VS Code dialog로
|
||||
// URL/폴더/언어/limit을 물어보고 Python을 spawn.
|
||||
await vscode.commands.executeCommand('g1nation.youtube.extractTranscripts', { url: typeof data.url === 'string' ? data.url : undefined });
|
||||
return true;
|
||||
case 'refreshModels':
|
||||
await provider._sendModels(true);
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user