diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json index bcab59b..c1f8a30 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": 1779073750556, + "createdAt": 1779084064068, "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 d22596b..6f6a02c 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": 1779073750553, + "createdAt": 1779084064066, "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 166296f..f08ca6a 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": 1779073750550, + "createdAt": 1779084064063, "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 e6b768e..9a622dc 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_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, + "result": "---\nid: stress_conflict_1779084064049\ndate: 2026-05-18T06:01:04.070Z\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]** 핵심 정보 수집 및 분석 중... (2ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (3ms)\n", + "createdAt": 1779084064070, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1779073750537.json b/.astra/tests/stress/.astra/missions/stress_conflict_1779084064049.json similarity index 82% rename from .astra/tests/stress/.astra/missions/stress_conflict_1779073750537.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1779084064049.json index 2912d7d..a9400d3 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1779073750537.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1779084064049.json @@ -1,7 +1,7 @@ { - "missionId": "stress_conflict_1779073750537", + "missionId": "stress_conflict_1779084064049", "status": "completed", - "startTime": "2026-05-18T03:09:10.537Z", + "startTime": "2026-05-18T06:01:04.049Z", "totalElapsedMs": 22, "results": { "planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", @@ -16,30 +16,30 @@ { "from": "idle", "to": "planner", - "durationMs": 12, + "durationMs": 13, "message": "전략 수립 중...", - "ts": "2026-05-18T03:09:10.549Z" + "ts": "2026-05-18T06:01:04.062Z" }, { "from": "planner", "to": "researcher", - "durationMs": 3, + "durationMs": 2, "message": "핵심 정보 수집 및 분석 중...", - "ts": "2026-05-18T03:09:10.552Z" + "ts": "2026-05-18T06:01:04.064Z" }, { "from": "researcher", "to": "writer", "durationMs": 3, "message": "최종 리포트 작성 및 편집 중...", - "ts": "2026-05-18T03:09:10.555Z" + "ts": "2026-05-18T06:01:04.067Z" }, { "from": "writer", "to": "completed", "durationMs": 4, "message": "미션 완료", - "ts": "2026-05-18T03:09:10.559Z" + "ts": "2026-05-18T06:01:04.071Z" } ], "resilienceMetrics": { diff --git a/assets/scripts/youtube_transcript.py b/assets/scripts/youtube_transcript.py deleted file mode 100644 index 032f632..0000000 --- a/assets/scripts/youtube_transcript.py +++ /dev/null @@ -1,397 +0,0 @@ -#!/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/media/sidebar.html b/media/sidebar.html index 60910cb..7ceb009 100644 --- a/media/sidebar.html +++ b/media/sidebar.html @@ -14,7 +14,6 @@
-