@dmsdc-ai/aigentry-devkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,231 @@
1
+ ---
2
+ name: env-manager
3
+ description: |
4
+ 전역 환경변수 관리 스킬. direnv 기반 계층형 환경변수 시스템 관리.
5
+ 키워드: env, 환경변수, environment, .env, direnv, env-check, 환경설정
6
+ allowed-tools: Bash, Read, Write, Edit, Glob, Grep
7
+ ---
8
+
9
+ # Environment Variable Manager
10
+
11
+ direnv 기반 계층형 환경변수 관리 시스템을 운영하는 스킬입니다.
12
+
13
+ ## 아키텍처
14
+
15
+ ```
16
+ ~/.env (전역 API 키 - Single Source of Truth)
17
+ ~/.envrc (direnv: dotenv_if_exists ~/.env)
18
+ |
19
+ +-- ~/Projects/<project>/
20
+ .envrc (source_up_if_exists + dotenv_if_exists .env.local)
21
+ .env.local (프로젝트 전용 변수)
22
+ .env (AUTO-GENERATED: ~/.env + .env.local 병합, Docker용)
23
+ scripts/generate-docker-env.sh (브릿지 스크립트)
24
+ ```
25
+
26
+ **변수 우선순위**: `.env.local` > `~/.env` (direnv source_up 체인)
27
+
28
+ ## 명령 분기
29
+
30
+ 사용자 요청에 따라 아래 워크플로우 중 하나를 실행합니다.
31
+
32
+ ### 1. 감사 (Audit)
33
+
34
+ **트리거**: "env check", "환경변수 점검", "env 상태"
35
+
36
+ ```bash
37
+ ~/bin/env-check
38
+ ```
39
+
40
+ 출력 항목:
41
+ - `[Files]` - 파일 목록 및 키 개수, 브릿지 파일 신선도
42
+ - `[Duplicates]` - 전역/로컬 간 중복 키 탐지
43
+ - `[Placeholders]` - 빈 값 또는 플레이스홀더 경고
44
+ - `[direnv Status]` - 각 디렉토리 direnv 허용 상태
45
+
46
+ ### 2. 새 프로젝트 초기화
47
+
48
+ **트리거**: "env init", "새 프로젝트 환경변수 설정"
49
+
50
+ 사용자에게 확인할 사항:
51
+ 1. 프로젝트 경로 (예: `~/Projects/my-project`)
52
+ 2. Docker 사용 여부 (브릿지 스크립트 필요 여부)
53
+
54
+ #### Step 1: .envrc 생성
55
+
56
+ ```bash
57
+ # ~/Projects/<project>/.envrc
58
+ source_up_if_exists
59
+ dotenv_if_exists .env.local
60
+ ```
61
+
62
+ Docker 사용 시 추가:
63
+ ```bash
64
+ bash scripts/generate-docker-env.sh 2>/dev/null || true
65
+ ```
66
+
67
+ #### Step 2: .env.local 생성
68
+
69
+ 프로젝트 전용 변수만 포함하는 파일 생성:
70
+ ```bash
71
+ # Project-specific environment variables
72
+ # Global API keys are inherited from ~/.env via direnv
73
+ ```
74
+
75
+ #### Step 3: 브릿지 스크립트 (Docker 사용 시)
76
+
77
+ `scripts/generate-docker-env.sh` 생성:
78
+ ```bash
79
+ #!/usr/bin/env bash
80
+ set -euo pipefail
81
+ PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
82
+ ENV_FILE="$PROJECT_DIR/.env"
83
+ ENV_LOCAL="$PROJECT_DIR/.env.local"
84
+ GLOBAL_ENV="$HOME/.env"
85
+ {
86
+ echo "# AUTO-GENERATED for Docker compatibility -- do not edit"
87
+ echo "# Edit ~/.env (global) or .env.local (project) instead"
88
+ echo "# Generated at: $(date -Iseconds)"
89
+ echo ""
90
+ echo "# === Global API Keys (from ~/.env) ==="
91
+ grep -v '^\s*#' "$GLOBAL_ENV" | grep -v '^\s*$' || true
92
+ echo ""
93
+ echo "# === Project-Specific (from .env.local) ==="
94
+ if [[ -f "$ENV_LOCAL" ]]; then
95
+ grep -v '^\s*#' "$ENV_LOCAL" | grep -v '^\s*$' || true
96
+ fi
97
+ } > "$ENV_FILE"
98
+ ```
99
+
100
+ ```bash
101
+ chmod +x scripts/generate-docker-env.sh
102
+ ```
103
+
104
+ #### Step 4: .gitignore 업데이트
105
+
106
+ `.gitignore`에 추가:
107
+ ```
108
+ .env
109
+ .env.local
110
+ ```
111
+
112
+ #### Step 5: .env.local.example 생성
113
+
114
+ 온보딩용 템플릿 (플레이스홀더 포함):
115
+ ```bash
116
+ # Project-specific environment variables
117
+ # Copy to .env.local and fill in values
118
+ # Global API keys are inherited from ~/.env via direnv
119
+ ```
120
+
121
+ #### Step 6: direnv allow
122
+
123
+ ```bash
124
+ cd ~/Projects/<project> && direnv allow
125
+ ```
126
+
127
+ #### Step 7: 브릿지 실행 (Docker 사용 시)
128
+
129
+ ```bash
130
+ cd ~/Projects/<project> && bash scripts/generate-docker-env.sh
131
+ ```
132
+
133
+ ### 3. 변수 추가/수정
134
+
135
+ **트리거**: "env add", "환경변수 추가", "API 키 추가"
136
+
137
+ 사용자에게 확인:
138
+ 1. 키 이름 (예: `NEW_API_KEY`)
139
+ 2. 값
140
+ 3. 범위: 전역(`~/.env`) 또는 프로젝트(`<project>/.env.local`)
141
+
142
+ #### 전역 변수 추가
143
+
144
+ ```bash
145
+ # ~/.env 에 추가
146
+ echo 'NEW_API_KEY=value' >> ~/.env
147
+ ```
148
+
149
+ 추가 후:
150
+ 1. direnv가 자동 반영 (새 셸에서)
151
+ 2. Docker 프로젝트의 브릿지 파일 재생성 필요:
152
+ ```bash
153
+ for project in shipfast n8n-video; do
154
+ if [[ -f "$HOME/Projects/$project/scripts/generate-docker-env.sh" ]]; then
155
+ bash "$HOME/Projects/$project/scripts/generate-docker-env.sh"
156
+ fi
157
+ done
158
+ ```
159
+
160
+ #### 프로젝트 변수 추가
161
+
162
+ ```bash
163
+ # ~/Projects/<project>/.env.local 에 추가
164
+ echo 'PROJECT_VAR=value' >> ~/Projects/<project>/.env.local
165
+ ```
166
+
167
+ Docker 사용 프로젝트라면 브릿지 재생성:
168
+ ```bash
169
+ bash ~/Projects/<project>/scripts/generate-docker-env.sh
170
+ ```
171
+
172
+ ### 4. 브릿지 재생성
173
+
174
+ **트리거**: "env regen", "브릿지 재생성", "docker env 갱신"
175
+
176
+ 특정 프로젝트:
177
+ ```bash
178
+ bash ~/Projects/<project>/scripts/generate-docker-env.sh
179
+ ```
180
+
181
+ 전체 프로젝트:
182
+ ```bash
183
+ for project_dir in ~/Projects/*/; do
184
+ if [[ -f "$project_dir/scripts/generate-docker-env.sh" ]]; then
185
+ project=$(basename "$project_dir")
186
+ echo "Regenerating: $project"
187
+ bash "$project_dir/scripts/generate-docker-env.sh"
188
+ fi
189
+ done
190
+ ```
191
+
192
+ ### 5. 변수 제거
193
+
194
+ **트리거**: "env remove", "환경변수 삭제"
195
+
196
+ 1. 대상 파일 확인 (`~/.env` 또는 `.env.local`)
197
+ 2. Edit 도구로 해당 라인 제거
198
+ 3. 브릿지 재생성 (Docker 프로젝트)
199
+
200
+ ### 6. 변수 검색
201
+
202
+ **트리거**: "env find", "환경변수 찾기", "어디에 정의되어 있어"
203
+
204
+ ```bash
205
+ # 전역에서 검색
206
+ grep -n "KEY_NAME" ~/.env
207
+
208
+ # 모든 프로젝트에서 검색
209
+ grep -rn "KEY_NAME" ~/.env ~/Projects/*/.env.local 2>/dev/null
210
+ ```
211
+
212
+ ## 파일 위치 참조
213
+
214
+ | 파일 | 용도 |
215
+ |------|------|
216
+ | `~/.env` | 전역 API 키 (SSOT) |
217
+ | `~/.envrc` | 전역 direnv 설정 |
218
+ | `~/bin/env-check` | 감사 스크립트 |
219
+ | `~/Projects/<p>/.envrc` | 프로젝트 direnv (source_up 체인) |
220
+ | `~/Projects/<p>/.env.local` | 프로젝트 전용 변수 |
221
+ | `~/Projects/<p>/.env` | Docker용 자동생성 파일 |
222
+ | `~/Projects/<p>/scripts/generate-docker-env.sh` | 브릿지 스크립트 |
223
+ | `~/Projects/<p>/.env.local.example` | 온보딩 템플릿 |
224
+
225
+ ## 주의사항
226
+
227
+ - `.env` 파일은 **절대 직접 편집하지 않음** (AUTO-GENERATED 표시 있는 파일)
228
+ - 전역 키는 반드시 `~/.env`에만 보관 (중복 금지)
229
+ - `python-dotenv`의 `load_dotenv(override=False)` 기본 동작과 호환
230
+ - Docker Compose는 셸 환경변수를 읽지 않으므로 브릿지 필수
231
+ - `.env.local`은 `.gitignore`에 포함 필수
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: youtube-analyzer
3
+ description: |
4
+ YouTube 영상의 메타데이터와 자막을 추출하여 내용을 분석합니다.
5
+ yt-dlp를 사용하여 영상을 다운로드하지 않고 자막과 정보만 추출합니다.
6
+ "유튜브 분석", "영상 분석", "youtube 분석", "이 영상 봐줘" 요청 시 사용합니다.
7
+ ---
8
+
9
+ # YouTube 영상 분석 스킬
10
+
11
+ YouTube URL을 입력받아 메타데이터와 자막/스크립트를 추출하고 내용을 분석합니다.
12
+
13
+ ## 필수 요구사항
14
+
15
+ ### 시스템 의존성
16
+ - Python 3.8+
17
+ - yt-dlp (`pip install yt-dlp`)
18
+
19
+ ### 설치 확인
20
+ ```bash
21
+ python3 -c "import yt_dlp; print('OK')"
22
+ ```
23
+
24
+ ## 워크플로우
25
+
26
+ ### 1단계: 영상 정보 추출
27
+ ```bash
28
+ python3 ~/.claude/skills/youtube-analyzer/scripts/analyze_youtube.py --url "YOUTUBE_URL"
29
+ ```
30
+ - 메타데이터 추출 (제목, 채널, 조회수, 설명 등)
31
+ - 자막/스크립트 다운로드 (한국어 우선, 영어 폴백)
32
+ - 마크다운 형식으로 출력
33
+
34
+ ### 2단계: 내용 분석
35
+ 추출된 텍스트를 기반으로:
36
+ - 영상 요약
37
+ - 핵심 포인트 정리
38
+ - 사용자 질문에 맞춰 분석
39
+
40
+ ## 사용 예시
41
+
42
+ ```
43
+ "이 유튜브 영상 분석해줘: https://youtube.com/watch?v=xxx"
44
+ "이 영상에서 핵심 내용 뽑아줘: https://youtu.be/xxx"
45
+ "유튜브 영상 요약: https://youtube.com/shorts/xxx"
46
+ ```
47
+
48
+ ## 지원 URL 형식
49
+ - `https://www.youtube.com/watch?v=VIDEO_ID`
50
+ - `https://youtu.be/VIDEO_ID`
51
+ - `https://www.youtube.com/shorts/VIDEO_ID`
52
+
53
+ ## 주의사항
54
+ 1. 비공개/삭제된 영상은 분석 불가
55
+ 2. 자막이 없는 영상은 메타데이터+설명만 분석
56
+ 3. 영상 자체는 다운로드하지 않음 (자막+메타데이터만)
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ YouTube 영상 메타데이터 및 자막 추출 스크립트.
4
+ yt-dlp Python API를 사용하여 영상 정보와 자막을 추출합니다.
5
+ """
6
+
7
+ import argparse
8
+ import os
9
+ import re
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+
15
+ def parse_args():
16
+ parser = argparse.ArgumentParser(
17
+ description="YouTube 영상 메타데이터 및 자막 추출"
18
+ )
19
+ parser.add_argument("--url", required=True, help="YouTube 영상 URL")
20
+ return parser.parse_args()
21
+
22
+
23
+ def format_duration(seconds):
24
+ if seconds is None:
25
+ return "알 수 없음"
26
+ seconds = int(seconds)
27
+ h = seconds // 3600
28
+ m = (seconds % 3600) // 60
29
+ s = seconds % 60
30
+ if h > 0:
31
+ return f"{h}:{m:02d}:{s:02d}"
32
+ return f"{m}:{s:02d}"
33
+
34
+
35
+ def format_number(n):
36
+ if n is None:
37
+ return "알 수 없음"
38
+ return f"{n:,}"
39
+
40
+
41
+ def format_date(date_str):
42
+ if not date_str:
43
+ return "알 수 없음"
44
+ # yt-dlp returns YYYYMMDD
45
+ if len(date_str) == 8:
46
+ return f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:]}"
47
+ return date_str
48
+
49
+
50
+ def extract_metadata(url):
51
+ """영상 메타데이터만 추출 (다운로드 없음)."""
52
+ try:
53
+ import yt_dlp
54
+ except ImportError:
55
+ print("오류: yt-dlp가 설치되어 있지 않습니다. pip install yt-dlp 로 설치하세요.", file=sys.stderr)
56
+ sys.exit(1)
57
+
58
+ ydl_opts = {
59
+ "quiet": True,
60
+ "no_warnings": True,
61
+ "skip_download": True,
62
+ "ignoreerrors": False,
63
+ }
64
+
65
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
66
+ info = ydl.extract_info(url, download=False)
67
+
68
+ return info
69
+
70
+
71
+ def extract_subtitles(url, tmpdir):
72
+ """자막을 추출합니다. 한국어 우선, 없으면 영어 폴백."""
73
+ try:
74
+ import yt_dlp
75
+ except ImportError:
76
+ return None, None
77
+
78
+ # 사용 가능한 자막 언어 확인
79
+ check_opts = {
80
+ "quiet": True,
81
+ "no_warnings": True,
82
+ "skip_download": True,
83
+ "ignoreerrors": False,
84
+ }
85
+
86
+ with yt_dlp.YoutubeDL(check_opts) as ydl:
87
+ info = ydl.extract_info(url, download=False)
88
+
89
+ available_subs = info.get("subtitles", {})
90
+ available_auto = info.get("automatic_captions", {})
91
+
92
+ # 언어 우선순위: ko, ko-KR, en, en-US
93
+ preferred_langs = ["ko", "ko-KR", "en", "en-US", "en-orig"]
94
+
95
+ chosen_lang = None
96
+ is_auto = False
97
+
98
+ for lang in preferred_langs:
99
+ if lang in available_subs:
100
+ chosen_lang = lang
101
+ is_auto = False
102
+ break
103
+
104
+ if chosen_lang is None:
105
+ for lang in preferred_langs:
106
+ if lang in available_auto:
107
+ chosen_lang = lang
108
+ is_auto = True
109
+ break
110
+
111
+ if chosen_lang is None:
112
+ # 아무 자막도 없으면 None 반환
113
+ return None, None
114
+
115
+ # 선택된 언어의 자막 다운로드
116
+ sub_opts = {
117
+ "quiet": True,
118
+ "no_warnings": True,
119
+ "skip_download": True,
120
+ "writesubtitles": not is_auto,
121
+ "writeautomaticsub": is_auto,
122
+ "subtitleslangs": [chosen_lang],
123
+ "subtitlesformat": "vtt/srt/best",
124
+ "outtmpl": os.path.join(tmpdir, "subtitle"),
125
+ "ignoreerrors": False,
126
+ }
127
+
128
+ with yt_dlp.YoutubeDL(sub_opts) as ydl:
129
+ ydl.download([url])
130
+
131
+ # 다운로드된 자막 파일 찾기
132
+ subtitle_file = None
133
+ for ext in ["vtt", "srt", "srv3", "ttml"]:
134
+ candidates = list(Path(tmpdir).glob(f"*.{ext}"))
135
+ if candidates:
136
+ subtitle_file = str(candidates[0])
137
+ break
138
+
139
+ if subtitle_file is None:
140
+ return None, chosen_lang
141
+
142
+ return subtitle_file, chosen_lang
143
+
144
+
145
+ def parse_vtt(content):
146
+ """WebVTT 자막 파싱하여 (timestamp, text) 목록 반환."""
147
+ lines = content.splitlines()
148
+ entries = []
149
+ i = 0
150
+
151
+ # WEBVTT 헤더 건너뛰기
152
+ while i < len(lines) and not lines[i].strip().startswith("00:") and "-->" not in lines[i]:
153
+ i += 1
154
+
155
+ while i < len(lines):
156
+ line = lines[i].strip()
157
+
158
+ # 타임스탬프 라인 감지
159
+ if "-->" in line:
160
+ timestamp_match = re.match(r"(\d{1,2}:\d{2}:\d{2}\.\d{3}|\d{2}:\d{2}\.\d{3})", line)
161
+ if timestamp_match:
162
+ timestamp = timestamp_match.group(1)
163
+ # HH:MM:SS.mmm 형식으로 정규화
164
+ if timestamp.count(":") == 1:
165
+ timestamp = "00:" + timestamp
166
+ # 밀리초 제거하여 간결하게
167
+ timestamp = timestamp[:8]
168
+
169
+ # 텍스트 수집
170
+ i += 1
171
+ text_lines = []
172
+ while i < len(lines) and lines[i].strip():
173
+ text_line = lines[i].strip()
174
+ # VTT 태그 제거 (<c>, </c>, <00:00:00.000> 등)
175
+ text_line = re.sub(r"<[^>]+>", "", text_line)
176
+ text_line = re.sub(r"&amp;", "&", text_line)
177
+ text_line = re.sub(r"&lt;", "<", text_line)
178
+ text_line = re.sub(r"&gt;", ">", text_line)
179
+ text_line = re.sub(r"&nbsp;", " ", text_line)
180
+ if text_line:
181
+ text_lines.append(text_line)
182
+ i += 1
183
+
184
+ if text_lines:
185
+ text = " ".join(text_lines)
186
+ entries.append((timestamp, text))
187
+ continue
188
+
189
+ i += 1
190
+
191
+ return entries
192
+
193
+
194
+ def parse_srt(content):
195
+ """SRT 자막 파싱하여 (timestamp, text) 목록 반환."""
196
+ entries = []
197
+ blocks = re.split(r"\n\n+", content.strip())
198
+
199
+ for block in blocks:
200
+ lines = block.strip().splitlines()
201
+ if len(lines) < 3:
202
+ continue
203
+
204
+ # 첫 줄: 번호 (건너뜀)
205
+ # 둘째 줄: 타임스탬프
206
+ timestamp_line = lines[1] if len(lines) > 1 else ""
207
+ ts_match = re.match(r"(\d{2}:\d{2}:\d{2})", timestamp_line)
208
+ if not ts_match:
209
+ continue
210
+
211
+ timestamp = ts_match.group(1)
212
+ text_lines = lines[2:]
213
+ text = " ".join(t.strip() for t in text_lines if t.strip())
214
+ # HTML 태그 제거
215
+ text = re.sub(r"<[^>]+>", "", text)
216
+
217
+ if text:
218
+ entries.append((timestamp, text))
219
+
220
+ return entries
221
+
222
+
223
+ def parse_subtitle_file(filepath):
224
+ """자막 파일을 파싱하여 (timestamp, text) 목록 반환."""
225
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
226
+ content = f.read()
227
+
228
+ ext = Path(filepath).suffix.lower()
229
+
230
+ if ext == ".vtt":
231
+ entries = parse_vtt(content)
232
+ elif ext == ".srt":
233
+ entries = parse_srt(content)
234
+ else:
235
+ # 알 수 없는 형식은 VTT로 시도 후 SRT 시도
236
+ entries = parse_vtt(content)
237
+ if not entries:
238
+ entries = parse_srt(content)
239
+
240
+ return entries
241
+
242
+
243
+ def find_overlap(prev, curr):
244
+ """이전 텍스트의 끝부분과 현재 텍스트의 시작부분이 겹치는 길이를 반환."""
245
+ max_overlap = min(len(prev), len(curr))
246
+ for i in range(max_overlap, 0, -1):
247
+ if prev.endswith(curr[:i]):
248
+ return i
249
+ return 0
250
+
251
+
252
+ def deduplicate_entries(entries):
253
+ """자동자막의 스크롤링 중복을 제거하고 깨끗한 텍스트로 병합."""
254
+ if not entries:
255
+ return entries
256
+
257
+ # 1단계: 겹침을 제거하며 전체 텍스트를 병합
258
+ merged_parts = [entries[0][1]]
259
+ timestamps = [entries[0][0]]
260
+
261
+ for ts, text in entries[1:]:
262
+ prev_text = merged_parts[-1]
263
+ # 완전 동일 → 무시
264
+ if text == prev_text:
265
+ continue
266
+ # 이전이 현재에 완전 포함 → 교체
267
+ if prev_text in text:
268
+ merged_parts[-1] = text
269
+ continue
270
+ # 현재가 이전에 완전 포함 → 무시
271
+ if text in prev_text:
272
+ continue
273
+ # 접미/접두 겹침 감지
274
+ overlap = find_overlap(prev_text, text)
275
+ if overlap > 5:
276
+ # 겹침 부분을 제거하고 새 부분만 추가
277
+ new_part = text[overlap:].strip()
278
+ if new_part:
279
+ merged_parts[-1] = prev_text + " " + new_part
280
+ continue
281
+ # 겹침 없음 → 새 항목
282
+ merged_parts.append(text)
283
+ timestamps.append(ts)
284
+
285
+ # 2단계: 적절한 길이로 분할하여 타임스탬프와 매핑
286
+ result = list(zip(timestamps, merged_parts))
287
+ return result
288
+
289
+
290
+ def format_subtitle_output(entries, lang):
291
+ """자막 엔트리를 읽기 쉬운 텍스트로 포맷."""
292
+ if not entries:
293
+ return "자막을 파싱할 수 없습니다."
294
+
295
+ lang_label = ""
296
+ if lang:
297
+ if lang.startswith("ko"):
298
+ lang_label = " (한국어)"
299
+ elif lang.startswith("en"):
300
+ lang_label = " (영어)"
301
+
302
+ lines = [f"*언어: {lang}{lang_label}*", ""]
303
+
304
+ for ts, text in entries:
305
+ lines.append(f"[{ts}] {text}")
306
+
307
+ return "\n".join(lines)
308
+
309
+
310
+ def main():
311
+ args = parse_args()
312
+ url = args.url
313
+
314
+ # 메타데이터 추출
315
+ try:
316
+ info = extract_metadata(url)
317
+ except Exception as e:
318
+ error_msg = str(e)
319
+ if "Private video" in error_msg:
320
+ print(f"오류: 비공개 영상입니다.", file=sys.stderr)
321
+ elif "Video unavailable" in error_msg or "not available" in error_msg.lower():
322
+ print(f"오류: 영상을 사용할 수 없습니다.", file=sys.stderr)
323
+ else:
324
+ print(f"오류: 영상 정보를 가져올 수 없습니다. {error_msg}", file=sys.stderr)
325
+ sys.exit(1)
326
+
327
+ title = info.get("title", "알 수 없음")
328
+ channel = info.get("channel") or info.get("uploader", "알 수 없음")
329
+ upload_date = format_date(info.get("upload_date"))
330
+ duration = format_duration(info.get("duration"))
331
+ view_count = format_number(info.get("view_count"))
332
+ like_count = format_number(info.get("like_count"))
333
+ tags = info.get("tags") or []
334
+ description = info.get("description") or ""
335
+
336
+ tags_str = ", ".join(tags[:20]) if tags else "없음"
337
+ if len(tags) > 20:
338
+ tags_str += f" 외 {len(tags) - 20}개"
339
+
340
+ # 설명 길이 제한 (너무 길면 잘라냄)
341
+ max_desc_len = 3000
342
+ if len(description) > max_desc_len:
343
+ description = description[:max_desc_len] + f"\n\n... (이하 생략, 총 {len(description)}자)"
344
+
345
+ # 자막 추출
346
+ subtitle_text = None
347
+ with tempfile.TemporaryDirectory() as tmpdir:
348
+ try:
349
+ subtitle_file, lang = extract_subtitles(url, tmpdir)
350
+ if subtitle_file:
351
+ entries = parse_subtitle_file(subtitle_file)
352
+ entries = deduplicate_entries(entries)
353
+ subtitle_text = format_subtitle_output(entries, lang)
354
+ elif lang:
355
+ subtitle_text = f"*언어 {lang}의 자막을 찾았으나 다운로드에 실패했습니다.*"
356
+ except Exception as e:
357
+ subtitle_text = f"*자막 추출 중 오류 발생: {e}*"
358
+
359
+ # 출력
360
+ output_parts = [
361
+ "# YouTube 영상 분석",
362
+ "",
363
+ "## 메타데이터",
364
+ f"- **제목**: {title}",
365
+ f"- **채널**: {channel}",
366
+ f"- **업로드일**: {upload_date}",
367
+ f"- **길이**: {duration}",
368
+ f"- **조회수**: {view_count}",
369
+ f"- **좋아요**: {like_count}",
370
+ f"- **태그**: {tags_str}",
371
+ "",
372
+ "## 설명",
373
+ description if description else "(설명 없음)",
374
+ "",
375
+ "## 자막/스크립트",
376
+ subtitle_text if subtitle_text else "이 영상에는 자막이 없습니다.",
377
+ ]
378
+
379
+ print("\n".join(output_parts))
380
+
381
+
382
+ if __name__ == "__main__":
383
+ main()