@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.
Files changed (45) hide show
  1. package/.claude/agents/code-review-handler.md +203 -0
  2. package/.claude/agents/issue-resolver.md +123 -0
  3. package/.claude/agents/python-pro.md +7 -2
  4. package/.claude/agents/web-researcher.md +5 -1
  5. package/.claude/commands/ask-deepwiki.md +46 -11
  6. package/.claude/commands/council.md +144 -36
  7. package/.claude/commands/gh/auto-review-loop.md +201 -0
  8. package/.claude/commands/gh/create-issue-label.md +4 -0
  9. package/.claude/commands/gh/decompose-issue.md +24 -2
  10. package/.claude/commands/gh/post-merge.md +52 -10
  11. package/.claude/commands/gh/resolve-and-review.md +69 -0
  12. package/.claude/commands/gh/resolve-issue.md +3 -0
  13. package/.claude/commands/tm/convert-prd.md +4 -0
  14. package/.claude/commands/tm/post-merge.md +7 -1
  15. package/.claude/commands/tm/resolve-issue.md +4 -0
  16. package/.claude/commands/tm/sync-to-github.md +4 -0
  17. package/.claude/settings.json +15 -0
  18. package/.claude/skills/claude-md-generator/SKILL.md +130 -0
  19. package/.claude/skills/claude-md-generator/references/examples.md +261 -0
  20. package/.claude/skills/claude-md-generator/references/templates.md +156 -0
  21. package/.claude/skills/hook-creator/SKILL.md +88 -0
  22. package/.claude/skills/hook-creator/references/examples.md +339 -0
  23. package/.claude/skills/hook-creator/references/hook-events.md +193 -0
  24. package/.claude/skills/skill-creator/SKILL.md +160 -13
  25. package/.claude/skills/skill-creator/references/output-patterns.md +82 -0
  26. package/.claude/skills/skill-creator/references/workflows.md +28 -0
  27. package/.claude/skills/skill-creator/scripts/package_skill.py +10 -10
  28. package/.claude/skills/skill-creator/scripts/quick_validate.py +45 -15
  29. package/.claude/skills/slash-command-creator/SKILL.md +108 -0
  30. package/.claude/skills/slash-command-creator/references/examples.md +161 -0
  31. package/.claude/skills/slash-command-creator/references/frontmatter.md +74 -0
  32. package/.claude/skills/slash-command-creator/scripts/init_command.py +221 -0
  33. package/.claude/skills/subagent-creator/SKILL.md +127 -0
  34. package/.claude/skills/subagent-creator/assets/subagent-template.md +31 -0
  35. package/.claude/skills/subagent-creator/references/available-tools.md +63 -0
  36. package/.claude/skills/subagent-creator/references/examples.md +213 -0
  37. package/.claude/skills/youtube-collector/README.md +107 -0
  38. package/.claude/skills/youtube-collector/SKILL.md +158 -0
  39. package/.claude/skills/youtube-collector/references/data-schema.md +110 -0
  40. package/.claude/skills/youtube-collector/scripts/collect_videos.py +304 -0
  41. package/.claude/skills/youtube-collector/scripts/fetch_transcript.py +138 -0
  42. package/.claude/skills/youtube-collector/scripts/fetch_videos.py +229 -0
  43. package/.claude/skills/youtube-collector/scripts/register_channel.py +247 -0
  44. package/.claude/skills/youtube-collector/scripts/setup_api_key.py +151 -0
  45. 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()