@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.
- package/LICENSE +21 -0
- package/README.md +500 -0
- package/bin/aigentry-devkit.js +94 -0
- package/config/CLAUDE.md +744 -0
- package/config/envrc/global.envrc +3 -0
- package/config/settings.json.template +12 -0
- package/hooks/hooks.json +16 -0
- package/hooks/session-start +37 -0
- package/hud/simple-status.sh +126 -0
- package/install.ps1 +203 -0
- package/install.sh +213 -0
- package/mcp-servers/deliberation/index.js +2429 -0
- package/mcp-servers/deliberation/package.json +16 -0
- package/mcp-servers/deliberation/session-monitor.sh +316 -0
- package/package.json +50 -0
- package/skills/clipboard-image/SKILL.md +31 -0
- package/skills/deliberation/SKILL.md +135 -0
- package/skills/deliberation-executor/SKILL.md +86 -0
- package/skills/env-manager/SKILL.md +231 -0
- package/skills/youtube-analyzer/SKILL.md +56 -0
- package/skills/youtube-analyzer/scripts/analyze_youtube.py +383 -0
|
@@ -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"&", "&", text_line)
|
|
177
|
+
text_line = re.sub(r"<", "<", text_line)
|
|
178
|
+
text_line = re.sub(r">", ">", text_line)
|
|
179
|
+
text_line = re.sub(r" ", " ", 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()
|