@yeongjaeyou/claude-code-config 0.15.0 → 0.17.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/.claude/agents/code-review-handler.md +203 -0
- package/.claude/agents/issue-resolver.md +123 -0
- package/.claude/agents/python-pro.md +7 -2
- package/.claude/agents/web-researcher.md +5 -1
- package/.claude/commands/ask-deepwiki.md +46 -11
- package/.claude/commands/council.md +144 -36
- package/.claude/commands/gh/auto-review-loop.md +201 -0
- package/.claude/commands/gh/create-issue-label.md +4 -0
- package/.claude/commands/gh/decompose-issue.md +24 -2
- package/.claude/commands/gh/post-merge.md +52 -10
- package/.claude/commands/gh/resolve-and-review.md +69 -0
- package/.claude/commands/gh/resolve-issue.md +3 -0
- package/.claude/commands/tm/convert-prd.md +4 -0
- package/.claude/commands/tm/post-merge.md +7 -1
- package/.claude/commands/tm/resolve-issue.md +4 -0
- package/.claude/commands/tm/sync-to-github.md +4 -0
- package/.claude/settings.json +15 -0
- package/.claude/skills/claude-md-generator/SKILL.md +130 -0
- package/.claude/skills/claude-md-generator/references/examples.md +261 -0
- package/.claude/skills/claude-md-generator/references/templates.md +156 -0
- package/.claude/skills/hook-creator/SKILL.md +88 -0
- package/.claude/skills/hook-creator/references/examples.md +339 -0
- package/.claude/skills/hook-creator/references/hook-events.md +193 -0
- package/.claude/skills/skill-creator/SKILL.md +160 -13
- package/.claude/skills/skill-creator/references/output-patterns.md +82 -0
- package/.claude/skills/skill-creator/references/workflows.md +28 -0
- package/.claude/skills/skill-creator/scripts/package_skill.py +10 -10
- package/.claude/skills/skill-creator/scripts/quick_validate.py +45 -15
- package/.claude/skills/slash-command-creator/SKILL.md +108 -0
- package/.claude/skills/slash-command-creator/references/examples.md +161 -0
- package/.claude/skills/slash-command-creator/references/frontmatter.md +74 -0
- package/.claude/skills/slash-command-creator/scripts/init_command.py +221 -0
- package/.claude/skills/subagent-creator/SKILL.md +127 -0
- package/.claude/skills/subagent-creator/assets/subagent-template.md +31 -0
- package/.claude/skills/subagent-creator/references/available-tools.md +63 -0
- package/.claude/skills/subagent-creator/references/examples.md +213 -0
- package/.claude/skills/youtube-collector/README.md +107 -0
- package/.claude/skills/youtube-collector/SKILL.md +158 -0
- package/.claude/skills/youtube-collector/references/data-schema.md +110 -0
- package/.claude/skills/youtube-collector/scripts/collect_videos.py +304 -0
- package/.claude/skills/youtube-collector/scripts/fetch_transcript.py +138 -0
- package/.claude/skills/youtube-collector/scripts/fetch_videos.py +229 -0
- package/.claude/skills/youtube-collector/scripts/register_channel.py +247 -0
- package/.claude/skills/youtube-collector/scripts/setup_api_key.py +151 -0
- package/package.json +1 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
채널의 영상을 수집하고 .reference/ 폴더에 YAML 파일로 저장하는 통합 스크립트
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python collect_videos.py --channel-id UC... --output-dir .reference/
|
|
7
|
+
python collect_videos.py --channel-handle @channelname --output-dir .reference/
|
|
8
|
+
python collect_videos.py --all --output-dir .reference/ # channels.yaml의 모든 채널
|
|
9
|
+
|
|
10
|
+
Output:
|
|
11
|
+
JSON 형식으로 처리 결과 요약 출력
|
|
12
|
+
|
|
13
|
+
Requirements:
|
|
14
|
+
pip install google-api-python-client youtube-transcript-api pyyaml
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# 같은 디렉토리의 모듈 import
|
|
25
|
+
script_dir = Path(__file__).parent
|
|
26
|
+
sys.path.insert(0, str(script_dir))
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from fetch_videos import (
|
|
30
|
+
load_api_key,
|
|
31
|
+
get_channel_id_from_handle,
|
|
32
|
+
fetch_videos,
|
|
33
|
+
)
|
|
34
|
+
from googleapiclient.discovery import build
|
|
35
|
+
except ImportError as e:
|
|
36
|
+
print(json.dumps({
|
|
37
|
+
"error": "필요한 모듈을 import할 수 없습니다.",
|
|
38
|
+
"detail": str(e),
|
|
39
|
+
"install": "pip install google-api-python-client pyyaml"
|
|
40
|
+
}))
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from fetch_transcript import fetch_transcript
|
|
45
|
+
except ImportError as e:
|
|
46
|
+
print(json.dumps({
|
|
47
|
+
"error": "fetch_transcript 모듈을 import할 수 없습니다.",
|
|
48
|
+
"detail": str(e),
|
|
49
|
+
"install": "pip install youtube-transcript-api"
|
|
50
|
+
}))
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
import yaml
|
|
55
|
+
except ImportError:
|
|
56
|
+
print(json.dumps({
|
|
57
|
+
"error": "pyyaml이 설치되어 있지 않습니다.",
|
|
58
|
+
"install": "pip install pyyaml"
|
|
59
|
+
}))
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_channels(output_dir: str) -> list:
|
|
64
|
+
"""channels.yaml에서 등록된 채널 목록 로드"""
|
|
65
|
+
channels_file = Path(output_dir) / "channels.yaml"
|
|
66
|
+
|
|
67
|
+
if not channels_file.exists():
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
with open(channels_file, 'r', encoding='utf-8') as f:
|
|
72
|
+
data = yaml.safe_load(f)
|
|
73
|
+
return data.get('channels', []) if data else []
|
|
74
|
+
except Exception:
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_existing_video_ids(output_dir: str, channel_handle: str) -> set:
|
|
79
|
+
"""채널 폴더에서 이미 수집된 video_id 목록 반환"""
|
|
80
|
+
# @ 제거
|
|
81
|
+
handle_clean = channel_handle.lstrip('@')
|
|
82
|
+
channel_dir = Path(output_dir) / "contents" / handle_clean
|
|
83
|
+
|
|
84
|
+
if not channel_dir.exists():
|
|
85
|
+
return set()
|
|
86
|
+
|
|
87
|
+
video_ids = set()
|
|
88
|
+
for yaml_file in channel_dir.glob("*.yaml"):
|
|
89
|
+
# 파일명이 video_id.yaml 형식
|
|
90
|
+
video_ids.add(yaml_file.stem)
|
|
91
|
+
|
|
92
|
+
return video_ids
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def save_video_yaml(output_dir: str, channel_handle: str, video_data: dict, transcript_data: dict) -> str:
|
|
96
|
+
"""영상 데이터를 YAML 파일로 저장"""
|
|
97
|
+
# @ 제거
|
|
98
|
+
handle_clean = channel_handle.lstrip('@')
|
|
99
|
+
channel_dir = Path(output_dir) / "contents" / handle_clean
|
|
100
|
+
|
|
101
|
+
# 폴더 생성
|
|
102
|
+
channel_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
# YAML 데이터 구성
|
|
105
|
+
yaml_data = {
|
|
106
|
+
'video_id': video_data['video_id'],
|
|
107
|
+
'title': video_data['title'],
|
|
108
|
+
'published_at': video_data['published_at'],
|
|
109
|
+
'url': video_data['url'],
|
|
110
|
+
'thumbnail': video_data['thumbnail'],
|
|
111
|
+
'description': video_data['description'],
|
|
112
|
+
'duration': video_data['duration'],
|
|
113
|
+
'collected_at': datetime.utcnow().isoformat() + "Z",
|
|
114
|
+
'transcript': {
|
|
115
|
+
'available': transcript_data['available'],
|
|
116
|
+
'language': transcript_data.get('language'),
|
|
117
|
+
'text': transcript_data.get('text')
|
|
118
|
+
},
|
|
119
|
+
'summary': None # AI가 나중에 추가
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# 파일 저장
|
|
123
|
+
file_path = channel_dir / f"{video_data['video_id']}.yaml"
|
|
124
|
+
|
|
125
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
126
|
+
yaml.dump(yaml_data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
|
127
|
+
|
|
128
|
+
return str(file_path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def collect_channel_videos(
|
|
132
|
+
youtube,
|
|
133
|
+
channel_id: str,
|
|
134
|
+
channel_handle: str,
|
|
135
|
+
output_dir: str,
|
|
136
|
+
max_results: int = 10,
|
|
137
|
+
language: str = 'ko',
|
|
138
|
+
skip_existing: bool = True
|
|
139
|
+
) -> dict:
|
|
140
|
+
"""단일 채널의 영상을 수집하고 저장"""
|
|
141
|
+
result = {
|
|
142
|
+
'channel': channel_handle,
|
|
143
|
+
'channel_id': channel_id,
|
|
144
|
+
'new_videos': [],
|
|
145
|
+
'skipped': 0,
|
|
146
|
+
'errors': []
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# 기존 video_id 목록
|
|
150
|
+
existing_ids = get_existing_video_ids(output_dir, channel_handle) if skip_existing else set()
|
|
151
|
+
|
|
152
|
+
# 영상 목록 조회
|
|
153
|
+
videos = fetch_videos(youtube, channel_id, max_results)
|
|
154
|
+
|
|
155
|
+
for video in videos:
|
|
156
|
+
video_id = video['video_id']
|
|
157
|
+
|
|
158
|
+
# 중복 체크
|
|
159
|
+
if video_id in existing_ids:
|
|
160
|
+
result['skipped'] += 1
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
# 자막 수집
|
|
165
|
+
transcript = fetch_transcript(video_id, language)
|
|
166
|
+
|
|
167
|
+
# YAML 저장
|
|
168
|
+
file_path = save_video_yaml(output_dir, channel_handle, video, transcript)
|
|
169
|
+
|
|
170
|
+
result['new_videos'].append({
|
|
171
|
+
'video_id': video_id,
|
|
172
|
+
'title': video['title'],
|
|
173
|
+
'file_path': file_path,
|
|
174
|
+
'transcript_available': transcript['available']
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
except Exception as e:
|
|
178
|
+
result['errors'].append({
|
|
179
|
+
'video_id': video_id,
|
|
180
|
+
'error': str(e)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main():
|
|
187
|
+
parser = argparse.ArgumentParser(description='YouTube 채널 영상 수집 및 저장')
|
|
188
|
+
parser.add_argument('--channel-id', help='채널 ID (UC...)')
|
|
189
|
+
parser.add_argument('--channel-handle', help='채널 핸들 (@username)')
|
|
190
|
+
parser.add_argument('--all', action='store_true', help='channels.yaml의 모든 채널 처리')
|
|
191
|
+
parser.add_argument('--output-dir', default='.reference', help='저장 디렉토리 (기본: .reference)')
|
|
192
|
+
parser.add_argument('--max-results', type=int, default=10, help='채널당 최대 수집 개수 (기본: 10)')
|
|
193
|
+
parser.add_argument('--language', default='ko', help='자막 우선 언어 (기본: ko)')
|
|
194
|
+
parser.add_argument('--no-skip-existing', action='store_true', help='기존 파일도 덮어쓰기')
|
|
195
|
+
parser.add_argument('--api-key', help='YouTube Data API 키 (미지정시 설정 파일에서 로드)')
|
|
196
|
+
|
|
197
|
+
args = parser.parse_args()
|
|
198
|
+
|
|
199
|
+
# 옵션 검증
|
|
200
|
+
if not args.all and not args.channel_id and not args.channel_handle:
|
|
201
|
+
print(json.dumps({
|
|
202
|
+
"error": "--all, --channel-id, --channel-handle 중 하나를 지정해야 합니다."
|
|
203
|
+
}))
|
|
204
|
+
sys.exit(1)
|
|
205
|
+
|
|
206
|
+
# API 키 로드
|
|
207
|
+
api_key = args.api_key or load_api_key()
|
|
208
|
+
if not api_key:
|
|
209
|
+
print(json.dumps({
|
|
210
|
+
"error": "YouTube Data API 키가 설정되지 않았습니다.",
|
|
211
|
+
"help": "python3 scripts/setup_api_key.py로 설정해주세요."
|
|
212
|
+
}))
|
|
213
|
+
sys.exit(1)
|
|
214
|
+
|
|
215
|
+
# YouTube API 초기화
|
|
216
|
+
try:
|
|
217
|
+
youtube = build('youtube', 'v3', developerKey=api_key)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
print(json.dumps({
|
|
220
|
+
"error": "YouTube API 초기화 실패",
|
|
221
|
+
"message": str(e)
|
|
222
|
+
}))
|
|
223
|
+
sys.exit(1)
|
|
224
|
+
|
|
225
|
+
# 처리할 채널 목록 결정
|
|
226
|
+
channels_to_process = []
|
|
227
|
+
|
|
228
|
+
if args.all:
|
|
229
|
+
# channels.yaml에서 모든 채널 로드
|
|
230
|
+
channels = load_channels(args.output_dir)
|
|
231
|
+
if not channels:
|
|
232
|
+
print(json.dumps({
|
|
233
|
+
"error": "등록된 채널이 없습니다.",
|
|
234
|
+
"help": "먼저 register_channel.py로 채널을 등록해주세요."
|
|
235
|
+
}))
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
|
|
238
|
+
for ch in channels:
|
|
239
|
+
channels_to_process.append({
|
|
240
|
+
'id': ch.get('id'),
|
|
241
|
+
'handle': ch.get('handle', '')
|
|
242
|
+
})
|
|
243
|
+
else:
|
|
244
|
+
# 단일 채널 처리
|
|
245
|
+
channel_id = args.channel_id
|
|
246
|
+
channel_handle = args.channel_handle or ''
|
|
247
|
+
|
|
248
|
+
if not channel_id and channel_handle:
|
|
249
|
+
channel_id = get_channel_id_from_handle(youtube, channel_handle)
|
|
250
|
+
if not channel_id:
|
|
251
|
+
print(json.dumps({
|
|
252
|
+
"error": f"채널을 찾을 수 없습니다: {channel_handle}"
|
|
253
|
+
}))
|
|
254
|
+
sys.exit(1)
|
|
255
|
+
|
|
256
|
+
channels_to_process.append({
|
|
257
|
+
'id': channel_id,
|
|
258
|
+
'handle': channel_handle
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
# 결과 수집
|
|
262
|
+
all_results = {
|
|
263
|
+
'processed_at': datetime.utcnow().isoformat() + "Z",
|
|
264
|
+
'output_dir': args.output_dir,
|
|
265
|
+
'channels_processed': 0,
|
|
266
|
+
'total_new_videos': 0,
|
|
267
|
+
'total_skipped': 0,
|
|
268
|
+
'results': [],
|
|
269
|
+
'errors': []
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
skip_existing = not args.no_skip_existing
|
|
273
|
+
|
|
274
|
+
for channel in channels_to_process:
|
|
275
|
+
try:
|
|
276
|
+
result = collect_channel_videos(
|
|
277
|
+
youtube=youtube,
|
|
278
|
+
channel_id=channel['id'],
|
|
279
|
+
channel_handle=channel['handle'],
|
|
280
|
+
output_dir=args.output_dir,
|
|
281
|
+
max_results=args.max_results,
|
|
282
|
+
language=args.language,
|
|
283
|
+
skip_existing=skip_existing
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
all_results['channels_processed'] += 1
|
|
287
|
+
all_results['total_new_videos'] += len(result['new_videos'])
|
|
288
|
+
all_results['total_skipped'] += result['skipped']
|
|
289
|
+
all_results['results'].append(result)
|
|
290
|
+
|
|
291
|
+
if result['errors']:
|
|
292
|
+
all_results['errors'].extend(result['errors'])
|
|
293
|
+
|
|
294
|
+
except Exception as e:
|
|
295
|
+
all_results['errors'].append({
|
|
296
|
+
'channel': channel.get('handle') or channel.get('id'),
|
|
297
|
+
'error': str(e)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
print(json.dumps(all_results, ensure_ascii=False, indent=2))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
main()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
YouTube 영상의 자막(transcript)을 가져오는 스크립트
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python fetch_transcript.py --video-id VIDEO_ID [--language ko]
|
|
7
|
+
|
|
8
|
+
Output:
|
|
9
|
+
JSON 형식으로 자막 출력
|
|
10
|
+
|
|
11
|
+
Requirements:
|
|
12
|
+
pip install youtube-transcript-api
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from youtube_transcript_api import YouTubeTranscriptApi
|
|
21
|
+
from youtube_transcript_api._errors import (
|
|
22
|
+
TranscriptsDisabled,
|
|
23
|
+
NoTranscriptFound,
|
|
24
|
+
VideoUnavailable,
|
|
25
|
+
CouldNotRetrieveTranscript
|
|
26
|
+
)
|
|
27
|
+
except ImportError:
|
|
28
|
+
print(json.dumps({
|
|
29
|
+
"error": "youtube-transcript-api가 설치되어 있지 않습니다.",
|
|
30
|
+
"install": "pip install youtube-transcript-api"
|
|
31
|
+
}))
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fetch_transcript(video_id: str, preferred_language: str = 'ko') -> dict:
|
|
36
|
+
"""
|
|
37
|
+
영상 자막 가져오기
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
video_id: YouTube 영상 ID
|
|
41
|
+
preferred_language: 우선 언어 코드 (기본: ko)
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
dict: {
|
|
45
|
+
'available': bool,
|
|
46
|
+
'language': str or None,
|
|
47
|
+
'text': str or None,
|
|
48
|
+
'segments': list or None,
|
|
49
|
+
'error': str or None
|
|
50
|
+
}
|
|
51
|
+
"""
|
|
52
|
+
result = {
|
|
53
|
+
'available': False,
|
|
54
|
+
'language': None,
|
|
55
|
+
'text': None,
|
|
56
|
+
'segments': None,
|
|
57
|
+
'error': None
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
# 사용 가능한 자막 목록 조회
|
|
62
|
+
transcript_list = YouTubeTranscriptApi.list_transcripts(video_id)
|
|
63
|
+
|
|
64
|
+
transcript = None
|
|
65
|
+
|
|
66
|
+
# 1. 우선 언어의 수동 자막 시도
|
|
67
|
+
try:
|
|
68
|
+
transcript = transcript_list.find_manually_created_transcript([preferred_language])
|
|
69
|
+
except NoTranscriptFound:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
# 2. 우선 언어의 자동 생성 자막 시도
|
|
73
|
+
if not transcript:
|
|
74
|
+
try:
|
|
75
|
+
transcript = transcript_list.find_generated_transcript([preferred_language])
|
|
76
|
+
except NoTranscriptFound:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
# 3. 영어 자막 시도 (한국어 없을 경우)
|
|
80
|
+
if not transcript and preferred_language != 'en':
|
|
81
|
+
try:
|
|
82
|
+
transcript = transcript_list.find_manually_created_transcript(['en'])
|
|
83
|
+
except NoTranscriptFound:
|
|
84
|
+
try:
|
|
85
|
+
transcript = transcript_list.find_generated_transcript(['en'])
|
|
86
|
+
except NoTranscriptFound:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
# 4. 아무 자막이나 가져오기
|
|
90
|
+
if not transcript:
|
|
91
|
+
try:
|
|
92
|
+
for t in transcript_list:
|
|
93
|
+
transcript = t
|
|
94
|
+
break
|
|
95
|
+
except:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
if transcript:
|
|
99
|
+
segments = transcript.fetch()
|
|
100
|
+
full_text = ' '.join([segment['text'] for segment in segments])
|
|
101
|
+
|
|
102
|
+
result['available'] = True
|
|
103
|
+
result['language'] = transcript.language_code
|
|
104
|
+
result['text'] = full_text
|
|
105
|
+
result['segments'] = segments
|
|
106
|
+
|
|
107
|
+
except TranscriptsDisabled:
|
|
108
|
+
result['error'] = "이 영상은 자막이 비활성화되어 있습니다."
|
|
109
|
+
except CouldNotRetrieveTranscript:
|
|
110
|
+
result['error'] = "이 영상에는 사용 가능한 자막이 없습니다."
|
|
111
|
+
except VideoUnavailable:
|
|
112
|
+
result['error'] = "영상을 찾을 수 없습니다."
|
|
113
|
+
except Exception as e:
|
|
114
|
+
result['error'] = str(e)
|
|
115
|
+
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main():
|
|
120
|
+
parser = argparse.ArgumentParser(description='YouTube 영상 자막 가져오기')
|
|
121
|
+
parser.add_argument('--video-id', required=True, help='영상 ID')
|
|
122
|
+
parser.add_argument('--language', default='ko', help='우선 언어 (기본: ko)')
|
|
123
|
+
parser.add_argument('--include-segments', action='store_true',
|
|
124
|
+
help='타임스탬프가 포함된 세그먼트 정보 포함')
|
|
125
|
+
|
|
126
|
+
args = parser.parse_args()
|
|
127
|
+
|
|
128
|
+
result = fetch_transcript(args.video_id, args.language)
|
|
129
|
+
|
|
130
|
+
# 세그먼트 정보 제외 옵션
|
|
131
|
+
if not args.include_segments and result.get('segments'):
|
|
132
|
+
del result['segments']
|
|
133
|
+
|
|
134
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
main()
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
YouTube 채널의 최신 영상 목록을 가져오는 스크립트
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python fetch_videos.py --channel-id UC... [--api-key YOUR_API_KEY] [--max-results 10]
|
|
7
|
+
python fetch_videos.py --channel-handle @channelname
|
|
8
|
+
|
|
9
|
+
API 키는 다음 위치에서 자동으로 로드됩니다:
|
|
10
|
+
- macOS/Linux: ~/.config/youtube-collector/config.yaml
|
|
11
|
+
- Windows: %APPDATA%\\youtube-collector\\config.yaml
|
|
12
|
+
|
|
13
|
+
Output:
|
|
14
|
+
JSON 형식으로 영상 목록 출력
|
|
15
|
+
|
|
16
|
+
Requirements:
|
|
17
|
+
pip install google-api-python-client pyyaml
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import platform
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from googleapiclient.discovery import build
|
|
29
|
+
from googleapiclient.errors import HttpError
|
|
30
|
+
except ImportError:
|
|
31
|
+
print(json.dumps({
|
|
32
|
+
"error": "google-api-python-client가 설치되어 있지 않습니다.",
|
|
33
|
+
"install": "pip install google-api-python-client"
|
|
34
|
+
}))
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import yaml
|
|
39
|
+
except ImportError:
|
|
40
|
+
print(json.dumps({
|
|
41
|
+
"error": "pyyaml이 설치되어 있지 않습니다.",
|
|
42
|
+
"install": "pip install pyyaml"
|
|
43
|
+
}))
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_api_key_config_path() -> str:
|
|
48
|
+
"""OS별 API 키 설정 파일 경로 반환"""
|
|
49
|
+
system = platform.system()
|
|
50
|
+
if system == "Windows":
|
|
51
|
+
base = os.environ.get("APPDATA", os.path.expanduser("~"))
|
|
52
|
+
return os.path.join(base, "youtube-collector", "config.yaml")
|
|
53
|
+
else: # macOS, Linux
|
|
54
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
|
|
55
|
+
return os.path.join(xdg_config, "youtube-collector", "config.yaml")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_api_key() -> str:
|
|
59
|
+
"""설정 파일에서 API 키 로드"""
|
|
60
|
+
config_path = get_api_key_config_path()
|
|
61
|
+
|
|
62
|
+
if not os.path.exists(config_path):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
67
|
+
config = yaml.safe_load(f)
|
|
68
|
+
return config.get('api_key') if config else None
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_channel_id_from_handle(youtube, handle: str) -> str:
|
|
74
|
+
"""채널 핸들(@username)로 채널 ID 조회"""
|
|
75
|
+
# @를 제거
|
|
76
|
+
handle = handle.lstrip('@')
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
response = youtube.search().list(
|
|
80
|
+
part="snippet",
|
|
81
|
+
q=f"@{handle}",
|
|
82
|
+
type="channel",
|
|
83
|
+
maxResults=1
|
|
84
|
+
).execute()
|
|
85
|
+
|
|
86
|
+
if response.get('items'):
|
|
87
|
+
return response['items'][0]['snippet']['channelId']
|
|
88
|
+
return None
|
|
89
|
+
except HttpError as e:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_channel_uploads_playlist_id(youtube, channel_id: str) -> str:
|
|
94
|
+
"""채널의 업로드 재생목록 ID 조회 (UC... -> UU...)"""
|
|
95
|
+
try:
|
|
96
|
+
response = youtube.channels().list(
|
|
97
|
+
part="contentDetails",
|
|
98
|
+
id=channel_id
|
|
99
|
+
).execute()
|
|
100
|
+
|
|
101
|
+
if response.get('items'):
|
|
102
|
+
return response['items'][0]['contentDetails']['relatedPlaylists']['uploads']
|
|
103
|
+
return None
|
|
104
|
+
except HttpError:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def fetch_videos(youtube, channel_id: str, max_results: int = 10) -> list:
|
|
109
|
+
"""채널의 최신 영상 목록 조회"""
|
|
110
|
+
videos = []
|
|
111
|
+
|
|
112
|
+
# 업로드 재생목록 ID 가져오기
|
|
113
|
+
uploads_playlist_id = get_channel_uploads_playlist_id(youtube, channel_id)
|
|
114
|
+
if not uploads_playlist_id:
|
|
115
|
+
return videos
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
# 재생목록에서 영상 ID 목록 가져오기
|
|
119
|
+
response = youtube.playlistItems().list(
|
|
120
|
+
part="snippet,contentDetails",
|
|
121
|
+
playlistId=uploads_playlist_id,
|
|
122
|
+
maxResults=min(max_results, 50)
|
|
123
|
+
).execute()
|
|
124
|
+
|
|
125
|
+
video_ids = [item['contentDetails']['videoId'] for item in response.get('items', [])]
|
|
126
|
+
|
|
127
|
+
if not video_ids:
|
|
128
|
+
return videos
|
|
129
|
+
|
|
130
|
+
# 영상 상세 정보 가져오기
|
|
131
|
+
videos_response = youtube.videos().list(
|
|
132
|
+
part="snippet,contentDetails",
|
|
133
|
+
id=','.join(video_ids)
|
|
134
|
+
).execute()
|
|
135
|
+
|
|
136
|
+
for item in videos_response.get('items', []):
|
|
137
|
+
snippet = item['snippet']
|
|
138
|
+
content_details = item['contentDetails']
|
|
139
|
+
|
|
140
|
+
# 썸네일 URL (최대 해상도 우선)
|
|
141
|
+
thumbnails = snippet.get('thumbnails', {})
|
|
142
|
+
thumbnail_url = (
|
|
143
|
+
thumbnails.get('maxres', {}).get('url') or
|
|
144
|
+
thumbnails.get('high', {}).get('url') or
|
|
145
|
+
thumbnails.get('medium', {}).get('url') or
|
|
146
|
+
thumbnails.get('default', {}).get('url', '')
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
videos.append({
|
|
150
|
+
'video_id': item['id'],
|
|
151
|
+
'title': snippet.get('title', ''),
|
|
152
|
+
'description': snippet.get('description', ''),
|
|
153
|
+
'published_at': snippet.get('publishedAt', ''),
|
|
154
|
+
'channel_id': snippet.get('channelId', ''),
|
|
155
|
+
'channel_title': snippet.get('channelTitle', ''),
|
|
156
|
+
'thumbnail': thumbnail_url,
|
|
157
|
+
'duration': content_details.get('duration', ''),
|
|
158
|
+
'url': f"https://youtube.com/watch?v={item['id']}"
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
return videos
|
|
162
|
+
|
|
163
|
+
except HttpError as e:
|
|
164
|
+
print(json.dumps({
|
|
165
|
+
"error": f"YouTube API 오류: {e.resp.status}",
|
|
166
|
+
"message": str(e)
|
|
167
|
+
}), file=sys.stderr)
|
|
168
|
+
return videos
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main():
|
|
172
|
+
parser = argparse.ArgumentParser(description='YouTube 채널의 최신 영상 목록 조회')
|
|
173
|
+
parser.add_argument('--channel-id', help='채널 ID (UC...)')
|
|
174
|
+
parser.add_argument('--channel-handle', help='채널 핸들 (@username)')
|
|
175
|
+
parser.add_argument('--api-key', help='YouTube Data API 키 (미지정시 설정 파일에서 로드)')
|
|
176
|
+
parser.add_argument('--max-results', type=int, default=10, help='최대 결과 수 (기본: 10)')
|
|
177
|
+
|
|
178
|
+
args = parser.parse_args()
|
|
179
|
+
|
|
180
|
+
if not args.channel_id and not args.channel_handle:
|
|
181
|
+
print(json.dumps({
|
|
182
|
+
"error": "--channel-id 또는 --channel-handle 중 하나를 지정해야 합니다."
|
|
183
|
+
}))
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
|
|
186
|
+
# API 키 결정: 인자 > 설정 파일
|
|
187
|
+
api_key = args.api_key or load_api_key()
|
|
188
|
+
|
|
189
|
+
if not api_key:
|
|
190
|
+
config_path = get_api_key_config_path()
|
|
191
|
+
print(json.dumps({
|
|
192
|
+
"error": "YouTube Data API 키가 설정되지 않았습니다.",
|
|
193
|
+
"help": f"다음 명령으로 API 키를 설정하세요: python3 setup_api_key.py",
|
|
194
|
+
"config_path": config_path
|
|
195
|
+
}))
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
youtube = build('youtube', 'v3', developerKey=api_key)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
print(json.dumps({
|
|
202
|
+
"error": "YouTube API 초기화 실패",
|
|
203
|
+
"message": str(e)
|
|
204
|
+
}))
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
|
|
207
|
+
# 채널 ID 결정
|
|
208
|
+
channel_id = args.channel_id
|
|
209
|
+
if not channel_id and args.channel_handle:
|
|
210
|
+
channel_id = get_channel_id_from_handle(youtube, args.channel_handle)
|
|
211
|
+
if not channel_id:
|
|
212
|
+
print(json.dumps({
|
|
213
|
+
"error": f"채널을 찾을 수 없습니다: {args.channel_handle}"
|
|
214
|
+
}))
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
# 영상 목록 조회
|
|
218
|
+
videos = fetch_videos(youtube, channel_id, args.max_results)
|
|
219
|
+
|
|
220
|
+
print(json.dumps({
|
|
221
|
+
"channel_id": channel_id,
|
|
222
|
+
"fetched_at": datetime.utcnow().isoformat() + "Z",
|
|
223
|
+
"count": len(videos),
|
|
224
|
+
"videos": videos
|
|
225
|
+
}, ensure_ascii=False, indent=2))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
main()
|