From 26fdce65258cdddd0a742646497a183799fb5c2f Mon Sep 17 00:00:00 2001 From: g1nation Date: Mon, 18 May 2026 12:14:27 +0900 Subject: [PATCH] Update --- ...d46d2ca2057b05c488be1dcf439166ac5a9a1.json | 2 +- ...9f4f39d2bc368f77456c37b5eef9a94a66b5c.json | 2 +- ...5c7a44d7661af673b24e3f49551a7a2e50280.json | 2 +- ...adc543795e4b427b64540a49c9ab27c7fe213.json | 4 +- ...son => stress_conflict_1779073750537.json} | 22 +- assets/scripts/youtube_transcript.py | 397 ++++++++++++++++++ docs/records/ConnectAI/chronicle.config.json | 10 +- media/sidebar.html | 1 + media/sidebar.js | 3 + package.json | 6 +- src/extension.ts | 6 + src/features/youtube/extractCommand.ts | 374 +++++++++++++++++ src/features/youtube/transcriptService.ts | 277 ++++++++++++ src/sidebar/chatHandlers.ts | 26 ++ 14 files changed, 1110 insertions(+), 22 deletions(-) rename .astra/tests/stress/.astra/missions/{stress_conflict_1778943957542.json => stress_conflict_1779073750537.json} (78%) create mode 100644 assets/scripts/youtube_transcript.py create mode 100644 src/features/youtube/extractCommand.ts create mode 100644 src/features/youtube/transcriptService.ts diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json index 583a86a..bcab59b 100644 --- a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json +++ b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1778943957572, + "createdAt": 1779073750556, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json index 60b225e..d22596b 100644 --- a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json +++ b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1778943957562, + "createdAt": 1779073750553, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json index 9be2637..166296f 100644 --- a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json +++ b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json @@ -1,5 +1,5 @@ { "result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", - "createdAt": 1778943957561, + "createdAt": 1779073750550, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json index 262ef33..e6b768e 100644 --- a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json +++ b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json @@ -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" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778943957542.json b/.astra/tests/stress/.astra/missions/stress_conflict_1779073750537.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1778943957542.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1779073750537.json index c55b9d3..2912d7d 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1778943957542.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1779073750537.json @@ -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": { diff --git a/assets/scripts/youtube_transcript.py b/assets/scripts/youtube_transcript.py new file mode 100644 index 0000000..032f632 --- /dev/null +++ b/assets/scripts/youtube_transcript.py @@ -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 \\ + --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})") + # 결과: ..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>, , 등) + 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) diff --git a/docs/records/ConnectAI/chronicle.config.json b/docs/records/ConnectAI/chronicle.config.json index 74bb1e7..58ebe35 100644 --- a/docs/records/ConnectAI/chronicle.config.json +++ b/docs/records/ConnectAI/chronicle.config.json @@ -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" } diff --git a/media/sidebar.html b/media/sidebar.html index 40d50b4..60910cb 100644 --- a/media/sidebar.html +++ b/media/sidebar.html @@ -46,6 +46,7 @@ + diff --git a/media/sidebar.js b/media/sidebar.js index 4d0c8a2..916cfd5 100644 --- a/media/sidebar.js +++ b/media/sidebar.js @@ -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; diff --git a/package.json b/package.json index 9885cc9..d574fca 100644 --- a/package.json +++ b/package.json @@ -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) 연결 📅" diff --git a/src/extension.ts b/src/extension.ts index 80574c2..93203e8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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); diff --git a/src/features/youtube/extractCommand.ts b/src/features/youtube/extractCommand.ts new file mode 100644 index 0000000..cec771e --- /dev/null +++ b/src/features/youtube/extractCommand.ts @@ -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 { + // 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 { + 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/` 형태가 필요'); + 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 ''; +} diff --git a/src/features/youtube/transcriptService.ts b/src/features/youtube/transcriptService.ts new file mode 100644 index 0000000..eba8dab --- /dev/null +++ b/src/features/youtube/transcriptService.ts @@ -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 ...` 형태로 호출 — 시스템 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; + 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 { + const candidates = explicit + ? [explicit] + : (process.platform === 'win32' ? ['python', 'py', 'python3'] : ['python3', 'python']); + for (const cmd of candidates) { + const ok = await new Promise((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 { + 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((resolve) => { + let proc: ReturnType; + 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, + }); + }); + }); +} diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts index 153928d..555a94d 100644 --- a/src/sidebar/chatHandlers.ts +++ b/src/sidebar/chatHandlers.ts @@ -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;