├── .python-version ├── main.py ├── yt2md ├── __init__.py ├── utils.py ├── markdown.py ├── transcript.py ├── cli.py └── video.py ├── CLAUDE.md ├── .gitignore ├── pyproject.toml ├── QUICKSTART.md ├── PRD.md ├── TODO.md ├── INSTALL.md ├── README.md └── uv.lock /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | print("Hello from yt2md!") 3 | 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /yt2md/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | yt2md - Convert YouTube videos to Markdown with screenshots and subtitles 3 | """ 4 | 5 | __version__ = "0.1.0" 6 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | * 유튜브 영상을 시청하는 것은 시간이 많이 소요됨. 2 | * 영상을 md 파일로 변환하고 싶음. 3 | * 방법은 커맨드라인 스크립트인데 사용자는 유뷰브 url을 제공하면 프로그램은 스크린샷과 그 아래 자막, 또 스크린샷과 그 아래 자막 등등 이런 식으로 md 파일이 생성되면 좋겠음. 4 | * 사용자는 md 파일을 스크롤 하면서 읽기만 하면 매우 편리함. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | 12 | # Output files 13 | output/ 14 | *.md 15 | !README.md 16 | !PRD.md 17 | !TODO.md 18 | !CLAUDE.md 19 | !INSTALL.md 20 | !QUICKSTART.md 21 | 22 | # Screenshots 23 | screenshots/ 24 | 25 | # Temporary files 26 | *.tmp 27 | *.log 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "yt2md" 3 | version = "0.1.0" 4 | description = "Convert YouTube videos to Markdown with screenshots and subtitles" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "yt-dlp>=2024.0.0", 9 | "youtube-transcript-api>=0.6.0", 10 | ] 11 | authors = [ 12 | { name = "practice", email = "practice@example.com" } 13 | ] 14 | license = { text = "MIT" } 15 | 16 | [project.urls] 17 | Homepage = "https://github.com/practice/yt2md" 18 | Repository = "https://github.com/practice/yt2md" 19 | Issues = "https://github.com/practice/yt2md/issues" 20 | 21 | [project.scripts] 22 | yt2md = "yt2md.cli:main" 23 | 24 | [build-system] 25 | requires = ["hatchling"] 26 | build-backend = "hatchling.build" 27 | -------------------------------------------------------------------------------- /QUICKSTART.md: -------------------------------------------------------------------------------- 1 | # 빠른 시작 가이드 2 | 3 | ## 한 줄 요약 4 | 5 | YouTube 영상을 스크린샷 + 자막이 포함된 Markdown 파일로 변환하는 도구 6 | 7 | ## 3단계로 시작하기 8 | 9 | ### 1️⃣ 설치 10 | 11 | ```bash 12 | # uv 설치 13 | curl -LsSf https://astral.sh/uv/install.sh | sh 14 | 15 | # ffmpeg 설치 (Ubuntu/Debian) 16 | sudo apt install ffmpeg 17 | 18 | # 프로젝트 클론 19 | git clone https://github.com/practice/yt2md.git 20 | cd yt2md 21 | uv sync 22 | ``` 23 | 24 | ### 2️⃣ 실행 25 | 26 | ```bash 27 | uv run yt2md "https://www.youtube.com/watch?v=a1a9wV88MSM" 28 | ``` 29 | 30 | ### 3️⃣ 결과 확인 31 | 32 | ```bash 33 | ls output/ 34 | # 꼭_알아야하는_클로드_코드_필수_꿀팁_60가지.md 35 | # screenshots/ 36 | ``` 37 | 38 | ## 주요 옵션 39 | 40 | ```bash 41 | # 스크린샷 간격 조정 (기본 30초) 42 | uv run yt2md "URL" --interval 60 43 | 44 | # 언어 지정 (기본 한국어) 45 | uv run yt2md "URL" --lang en 46 | 47 | # 출력 파일명 지정 48 | uv run yt2md "URL" --output "my_video.md" 49 | 50 | # 도움말 51 | uv run yt2md --help 52 | ``` 53 | 54 | ## 더 자세한 내용 55 | 56 | - 📖 [README.md](README.md) - 전체 문서 57 | - 🔧 [INSTALL.md](INSTALL.md) - 상세 설치 가이드 58 | - 📋 [PRD.md](PRD.md) - 프로젝트 요구사항 59 | - ✅ [TODO.md](TODO.md) - 개발 현황 60 | 61 | ## 문제 해결 62 | 63 | ### ffmpeg 오류 64 | ```bash 65 | sudo apt install ffmpeg 66 | ``` 67 | 68 | ### uv를 찾을 수 없음 69 | ```bash 70 | source ~/.bashrc 71 | ``` 72 | 73 | ### 자막이 없음 74 | - YouTube에서 자막 활성화 확인 75 | - 자동 생성 자막도 지원됨 76 | 77 | ## GitHub 78 | 79 | - 저장소: https://github.com/practice/yt2md 80 | - 이슈: https://github.com/practice/yt2md/issues 81 | -------------------------------------------------------------------------------- /yt2md/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions for yt2md 3 | """ 4 | 5 | import re 6 | from urllib.parse import urlparse, parse_qs 7 | 8 | 9 | def parse_youtube_url(url: str) -> str | None: 10 | """ 11 | Parse YouTube URL and extract video ID 12 | 13 | Supports formats: 14 | - https://www.youtube.com/watch?v=VIDEO_ID 15 | - https://youtu.be/VIDEO_ID 16 | - https://www.youtube.com/embed/VIDEO_ID 17 | - https://m.youtube.com/watch?v=VIDEO_ID 18 | 19 | Args: 20 | url: YouTube video URL 21 | 22 | Returns: 23 | Video ID if valid, None otherwise 24 | """ 25 | # Pattern for youtu.be short URLs 26 | short_pattern = r'(?:youtu\.be/)([a-zA-Z0-9_-]{11})' 27 | match = re.search(short_pattern, url) 28 | if match: 29 | return match.group(1) 30 | 31 | # Pattern for standard YouTube URLs 32 | parsed = urlparse(url) 33 | 34 | if parsed.hostname in ('www.youtube.com', 'youtube.com', 'm.youtube.com'): 35 | if parsed.path == '/watch': 36 | query_params = parse_qs(parsed.query) 37 | return query_params.get('v', [None])[0] 38 | elif parsed.path.startswith('/embed/'): 39 | return parsed.path.split('/')[2] 40 | 41 | return None 42 | 43 | 44 | def format_timestamp(seconds: float) -> str: 45 | """ 46 | Convert seconds to HH:MM:SS format 47 | 48 | Args: 49 | seconds: Time in seconds 50 | 51 | Returns: 52 | Formatted timestamp string 53 | """ 54 | hours = int(seconds // 3600) 55 | minutes = int((seconds % 3600) // 60) 56 | secs = int(seconds % 60) 57 | 58 | if hours > 0: 59 | return f"{hours:02d}:{minutes:02d}:{secs:02d}" 60 | else: 61 | return f"{minutes:02d}:{secs:02d}" 62 | 63 | 64 | def sanitize_filename(filename: str) -> str: 65 | """ 66 | Sanitize filename by removing invalid characters 67 | 68 | Args: 69 | filename: Original filename 70 | 71 | Returns: 72 | Sanitized filename 73 | """ 74 | # Remove invalid characters for filenames 75 | invalid_chars = r'[<>:"/\\|?*]' 76 | sanitized = re.sub(invalid_chars, '_', filename) 77 | 78 | # Remove leading/trailing spaces and dots 79 | sanitized = sanitized.strip('. ') 80 | 81 | # Limit length 82 | max_length = 200 83 | if len(sanitized) > max_length: 84 | sanitized = sanitized[:max_length] 85 | 86 | return sanitized 87 | -------------------------------------------------------------------------------- /PRD.md: -------------------------------------------------------------------------------- 1 | # Product Requirements Document (PRD) 2 | ## yt2md - YouTube Video to Markdown Converter 3 | 4 | ### 1. 개요 5 | YouTube 영상을 스크린샷과 자막이 포함된 Markdown 파일로 변환하여, 사용자가 영상을 시청하는 대신 문서를 스크롤하며 빠르게 내용을 파악할 수 있도록 하는 커맨드라인 도구. 6 | 7 | ### 2. 문제 정의 8 | - YouTube 영상 시청은 시간이 많이 소요됨 9 | - 영상 내용을 빠르게 훑어보거나 참조하기 어려움 10 | - 텍스트 기반 검색 및 참조가 불가능함 11 | 12 | ### 3. 목표 13 | YouTube 영상의 핵심 내용을 시각적 스크린샷과 텍스트 자막으로 구성된 읽기 편한 Markdown 문서로 변환 14 | 15 | ### 4. 주요 기능 16 | 17 | #### 4.1 입력 18 | - YouTube URL을 커맨드라인 인자로 받음 19 | - 지원 형식: 20 | - `https://www.youtube.com/watch?v=VIDEO_ID` 21 | - `https://youtu.be/VIDEO_ID` 22 | 23 | #### 4.2 처리 과정 24 | 1. YouTube 영상에서 자막 추출 25 | 2. 일정 간격으로 영상 스크린샷 캡처 26 | 3. 스크린샷과 해당 시점의 자막을 매칭 27 | 4. Markdown 형식으로 구성 28 | 29 | #### 4.3 출력 30 | - 생성되는 Markdown 파일 구조: 31 | ```markdown 32 | # [영상 제목] 33 | 34 | ## 영상 정보 35 | - URL: [YouTube URL] 36 | - 길이: [영상 길이] 37 | - 생성일: [변환 일시] 38 | 39 | --- 40 | 41 | ## 내용 42 | 43 | ### [00:00:00] 44 | ![Screenshot](./screenshots/screenshot_001.png) 45 | 46 | 자막 내용... 47 | 48 | ### [00:00:30] 49 | ![Screenshot](./screenshots/screenshot_002.png) 50 | 51 | 자막 내용... 52 | ``` 53 | 54 | ### 5. 기술 요구사항 55 | 56 | #### 5.1 프로그래밍 언어 57 | - Python 또는 Node.js 추천 58 | 59 | #### 5.2 필요한 라이브러리/도구 60 | - YouTube 자막 다운로드: `youtube-transcript-api` (Python) 또는 `youtubei.js` (Node.js) 61 | - 영상 처리: `yt-dlp`, `ffmpeg` 62 | - 스크린샷 추출: `ffmpeg` 63 | 64 | #### 5.3 성능 요구사항 65 | - 10분 영상 기준 5분 이내 변환 66 | - 스크린샷 간격: 30초 ~ 1분 (설정 가능) 67 | 68 | ### 6. 사용 예시 69 | 70 | ```bash 71 | # 기본 사용 72 | yt2md "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 73 | 74 | # 스크린샷 간격 지정 (초 단위) 75 | yt2md "https://www.youtube.com/watch?v=dQw4w9WgXcQ" --interval 45 76 | 77 | # 출력 파일명 지정 78 | yt2md "https://www.youtube.com/watch?v=dQw4w9WgXcQ" --output "my_video.md" 79 | 80 | # 자막 언어 지정 81 | yt2md "https://www.youtube.com/watch?v=dQw4w9WgXcQ" --lang ko 82 | ``` 83 | 84 | ### 7. 출력 파일 구조 85 | 86 | ``` 87 | output/ 88 | ├── video_title.md # 메인 Markdown 파일 89 | └── screenshots/ # 스크린샷 폴더 90 | ├── screenshot_001.png 91 | ├── screenshot_002.png 92 | └── ... 93 | ``` 94 | 95 | ### 8. 향후 개선 사항 (Optional) 96 | - 자동 요약 기능 (AI 활용) 97 | - 여러 자막 언어 동시 표시 98 | - HTML 출력 옵션 99 | - 특정 구간만 변환하는 기능 100 | - 스크린샷 품질 설정 101 | - 진행률 표시 102 | 103 | ### 9. 제약 사항 104 | - YouTube API 사용 제한 고려 105 | - 자막이 없는 영상은 처리 불가 (또는 경고 표시) 106 | - 저작권이 있는 콘텐츠 처리 시 주의 필요 107 | - 영상 길이에 따른 처리 시간 증가 108 | 109 | ### 10. 성공 지표 110 | - 사용자가 영상 시청 시간 대비 50% 이상 시간 절약 111 | - Markdown 파일의 가독성 및 활용성 112 | - 안정적인 변환 성공률 (95% 이상) 113 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO List - yt2md 프로젝트 2 | 3 | ## Phase 1: 프로젝트 셋업 4 | - [ ] 프로젝트 디렉토리 구조 설계 5 | - [ ] `uv`를 사용한 Python 프로젝트 초기화 (`uv init`) 6 | - [ ] `pyproject.toml` 파일 설정 7 | - [ ] 필요한 라이브러리 추가 (uv를 통해) 8 | - [ ] `yt-dlp` (YouTube 영상 다운로드) 9 | - [ ] `youtube-transcript-api` (자막 추출) 10 | - [ ] `Pillow` (이미지 처리, 선택사항) 11 | - [ ] `ffmpeg` 시스템 의존성 확인 및 설치 가이드 작성 12 | - [ ] `.gitignore` 파일 작성 13 | 14 | ## Phase 2: 핵심 기능 구현 15 | 16 | ### 2.1 YouTube 자막 추출 17 | - [ ] YouTube URL 파싱 함수 작성 18 | - [ ] 자막 다운로드 함수 구현 19 | - [ ] 자막이 없는 경우 에러 핸들링 20 | - [ ] 여러 언어 자막 지원 (기본값: 한국어) 21 | - [ ] 자막 타임스탬프 파싱 22 | 23 | ### 2.2 영상 스크린샷 캡처 24 | - [ ] `ffmpeg`를 이용한 스크린샷 추출 함수 작성 25 | - [ ] 스크린샷 간격 설정 기능 (기본값: 30초) 26 | - [ ] 스크린샷 저장 디렉토리 생성 27 | - [ ] 스크린샷 파일명 규칙 정의 (예: `screenshot_001.png`) 28 | - [ ] 스크린샷 품질/해상도 설정 옵션 29 | 30 | ### 2.3 자막과 스크린샷 매칭 31 | - [ ] 타임스탬프 기반 자막-스크린샷 매핑 로직 구현 32 | - [ ] 각 스크린샷 시점의 자막 구간 추출 33 | - [ ] 자막이 없는 구간 처리 34 | 35 | ## Phase 3: Markdown 생성 36 | 37 | ### 3.1 Markdown 포맷팅 38 | - [ ] Markdown 파일 헤더 생성 (제목, 메타데이터) 39 | - [ ] 스크린샷 이미지 삽입 포맷 작성 40 | - [ ] 자막 텍스트 포맷팅 41 | - [ ] 타임스탬프 표시 형식 결정 42 | - [ ] 목차(TOC) 자동 생성 (선택사항) 43 | 44 | ### 3.2 파일 출력 45 | - [ ] Markdown 파일 생성 함수 작성 46 | - [ ] 출력 디렉토리 구조 생성 47 | - [ ] 파일명 자동 생성 (영상 제목 기반) 48 | - [ ] 파일명 sanitization (특수문자 처리) 49 | 50 | ## Phase 4: CLI 인터페이스 51 | 52 | ### 4.1 커맨드라인 인자 처리 53 | - [ ] `argparse` 또는 `click` 라이브러리 선택 및 설정 54 | - [ ] 필수 인자: YouTube URL 55 | - [ ] 선택 인자: 56 | - [ ] `--interval`: 스크린샷 간격 (초) 57 | - [ ] `--output`: 출력 파일명 58 | - [ ] `--lang`: 자막 언어 59 | - [ ] `--quality`: 스크린샷 품질 60 | - [ ] `--help`: 도움말 61 | 62 | ### 4.2 사용자 피드백 63 | - [ ] 진행률 표시 (프로그레스 바) 64 | - [ ] 단계별 로그 출력 65 | - [ ] 에러 메시지 개선 66 | - [ ] 완료 메시지 및 결과 파일 경로 출력 67 | 68 | ## Phase 5: 에러 핸들링 및 최적화 69 | 70 | ### 5.1 에러 처리 71 | - [ ] 잘못된 URL 형식 검증 72 | - [ ] 네트워크 에러 핸들링 73 | - [ ] 자막이 없는 영상 처리 74 | - [ ] 디스크 공간 부족 에러 처리 75 | - [ ] `ffmpeg` 미설치 에러 안내 76 | 77 | ### 5.2 성능 최적화 78 | - [ ] 불필요한 영상 다운로드 최소화 79 | - [ ] 스크린샷 추출 성능 개선 80 | - [ ] 메모리 사용량 최적화 81 | - [ ] 긴 영상 처리 시간 테스트 82 | 83 | ## Phase 6: 테스트 및 문서화 84 | 85 | ### 6.1 테스트 86 | - [ ] 단위 테스트 작성 87 | - [ ] URL 파싱 테스트 88 | - [ ] 자막 추출 테스트 89 | - [ ] Markdown 생성 테스트 90 | - [ ] 통합 테스트 작성 91 | - [ ] 다양한 YouTube 영상으로 테스트 92 | - [ ] 짧은 영상 (< 5분) 93 | - [ ] 중간 영상 (5-30분) 94 | - [ ] 긴 영상 (> 30분) 95 | 96 | ### 6.2 문서화 97 | - [ ] `README.md` 작성 98 | - [ ] 프로젝트 소개 99 | - [ ] 설치 방법 100 | - [ ] 사용 예시 101 | - [ ] 트러블슈팅 102 | - [ ] 코드 주석 추가 103 | - [ ] 예제 출력 파일 준비 104 | - [ ] 스크린샷/GIF 데모 준비 (선택사항) 105 | 106 | ## Phase 7: 배포 및 패키징 107 | 108 | - [ ] `pyproject.toml`에 프로젝트 메타데이터 완성 109 | - [ ] `uv build`를 사용한 패키지 빌드 110 | - [ ] PyPI 배포 준비 (선택사항) 111 | - [ ] 실행 가능한 스크립트 생성 (console scripts 설정) 112 | - [ ] 버전 관리 전략 수립 113 | - [ ] 라이선스 파일 추가 114 | 115 | ## 추가 개선 사항 (Optional) 116 | 117 | - [ ] GUI 버전 개발 118 | - [ ] 웹 인터페이스 추가 119 | - [ ] AI 기반 자동 요약 기능 120 | - [ ] 여러 영상 배치 처리 121 | - [ ] 구간 선택 기능 (시작~끝 시간) 122 | - [ ] HTML 출력 옵션 123 | - [ ] PDF 변환 기능 124 | - [ ] 영상 썸네일 추가 125 | - [ ] 챕터 정보 추출 (있는 경우) 126 | 127 | --- 128 | 129 | ## 진행 상태 130 | 131 | - **시작일**: [날짜] 132 | - **현재 Phase**: Phase 1 133 | - **완료율**: 0% 134 | 135 | ## 참고사항 136 | 137 | - 작업 시작 전 `PRD.md` 문서 참조 138 | - 각 Phase 완료 후 테스트 진행 139 | - 이슈 발생 시 GitHub Issues에 등록 140 | -------------------------------------------------------------------------------- /yt2md/markdown.py: -------------------------------------------------------------------------------- 1 | """ 2 | Markdown generation module 3 | """ 4 | 5 | from pathlib import Path 6 | from typing import List, Dict, Tuple 7 | from datetime import datetime 8 | from .utils import format_timestamp 9 | from .transcript import get_text_for_interval 10 | 11 | 12 | def generate_markdown( 13 | video_info: Dict[str, any], 14 | screenshots: List[Tuple[int, Path]], 15 | transcript: List[Dict[str, any]], 16 | video_id: str, 17 | output_path: Path, 18 | screenshot_dir: Path, 19 | ) -> None: 20 | """ 21 | Generate Markdown file with screenshots and subtitles 22 | 23 | Args: 24 | video_info: Dictionary with video metadata 25 | screenshots: List of (timestamp, screenshot_path) tuples 26 | transcript: List of transcript entries 27 | video_id: YouTube video ID 28 | output_path: Path for output markdown file 29 | screenshot_dir: Directory containing screenshots 30 | """ 31 | lines = [] 32 | 33 | # Header 34 | title = video_info.get('title', 'Unknown Title') 35 | lines.append(f"# {title}\n") 36 | 37 | # Video Information 38 | lines.append("## Video Information\n") 39 | lines.append(f"- **URL**: https://www.youtube.com/watch?v={video_id}") 40 | lines.append(f"- **Duration**: {format_timestamp(video_info.get('duration', 0))}") 41 | lines.append(f"- **Uploader**: {video_info.get('uploader', 'Unknown')}") 42 | lines.append(f"- **Upload Date**: {video_info.get('upload_date', 'Unknown')}") 43 | lines.append(f"- **Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") 44 | lines.append("") 45 | lines.append("---\n") 46 | 47 | # Content sections 48 | lines.append("## Content\n") 49 | 50 | for i, (timestamp, screenshot_path) in enumerate(screenshots, start=1): 51 | # Calculate interval end (next screenshot or video end) 52 | if i < len(screenshots): 53 | next_timestamp = screenshots[i][0] 54 | else: 55 | next_timestamp = video_info.get('duration', timestamp + 30) 56 | 57 | # Section header with timestamp 58 | time_str = format_timestamp(timestamp) 59 | lines.append(f"### [{time_str}]\n") 60 | 61 | # Screenshot image 62 | # Use relative path from markdown file to screenshot 63 | rel_screenshot_path = Path(screenshot_path.name) 64 | lines.append(f"![Screenshot at {time_str}](screenshots/{rel_screenshot_path})\n") 65 | 66 | # Get subtitle text for this interval 67 | subtitle_text = get_text_for_interval(transcript, timestamp, next_timestamp) 68 | 69 | if subtitle_text: 70 | lines.append(f"{subtitle_text}\n") 71 | else: 72 | lines.append("*No subtitles available for this segment*\n") 73 | 74 | lines.append("---\n") 75 | 76 | # Write to file 77 | output_path.parent.mkdir(parents=True, exist_ok=True) 78 | 79 | with open(output_path, 'w', encoding='utf-8') as f: 80 | f.write('\n'.join(lines)) 81 | 82 | 83 | def create_simple_markdown( 84 | title: str, 85 | video_url: str, 86 | content: str, 87 | output_path: Path 88 | ) -> None: 89 | """ 90 | Create a simple markdown file (for testing or fallback) 91 | 92 | Args: 93 | title: Document title 94 | video_url: YouTube video URL 95 | content: Main content 96 | output_path: Path for output markdown file 97 | """ 98 | lines = [ 99 | f"# {title}\n", 100 | f"**Source**: {video_url}\n", 101 | "---\n", 102 | content 103 | ] 104 | 105 | output_path.parent.mkdir(parents=True, exist_ok=True) 106 | 107 | with open(output_path, 'w', encoding='utf-8') as f: 108 | f.write('\n'.join(lines)) 109 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # 설치 가이드 (다른 머신에서 실행하기) 2 | 3 | 다른 컴퓨터에서 yt2md를 설치하고 실행하는 단계별 가이드입니다. 4 | 5 | ## 1단계: 사전 요구사항 설치 6 | 7 | ### Python 설치 확인 8 | 9 | ```bash 10 | # Python 버전 확인 (3.10 이상 필요) 11 | python3 --version 12 | 13 | # 없다면 설치 14 | # Ubuntu/Debian 15 | sudo apt update 16 | sudo apt install python3 python3-pip 17 | 18 | # macOS (Homebrew 사용) 19 | brew install python@3.11 20 | ``` 21 | 22 | ### uv 설치 23 | 24 | ```bash 25 | # uv 설치 (Python 패키지 관리자) 26 | curl -LsSf https://astral.sh/uv/install.sh | sh 27 | 28 | # 설치 후 쉘 재시작 또는 29 | source ~/.bashrc # 또는 ~/.zshrc 30 | 31 | # 설치 확인 32 | uv --version 33 | ``` 34 | 35 | ### ffmpeg 설치 36 | 37 | ```bash 38 | # Ubuntu/Debian 39 | sudo apt update 40 | sudo apt install ffmpeg 41 | 42 | # macOS 43 | brew install ffmpeg 44 | 45 | # Fedora/RHEL 46 | sudo dnf install ffmpeg 47 | 48 | # 설치 확인 49 | ffmpeg -version 50 | ``` 51 | 52 | ## 2단계: 프로젝트 가져오기 53 | 54 | ### 방법 1: Git Clone (저장소가 있는 경우) 55 | 56 | ```bash 57 | # GitHub에서 클론 58 | git clone https://github.com/practice/yt2md.git 59 | cd yt2md 60 | ``` 61 | 62 | ### 방법 2: 압축 파일 다운로드 63 | 64 | ```bash 65 | # 프로젝트 폴더를 압축하여 다른 머신으로 복사 66 | # 원본 머신에서: 67 | tar -czf yt2md.tar.gz yt2md/ 68 | 69 | # 새 머신에서: 70 | tar -xzf yt2md.tar.gz 71 | cd yt2md 72 | ``` 73 | 74 | ### 방법 3: 직접 파일 복사 75 | 76 | 프로젝트 폴더 전체를 USB, 클라우드 등으로 복사 77 | 78 | ## 3단계: 의존성 설치 79 | 80 | ```bash 81 | # 프로젝트 디렉토리에서 82 | cd yt2md 83 | 84 | # 의존성 설치 (자동으로 가상환경 생성) 85 | uv sync 86 | 87 | # 설치 확인 88 | uv run yt2md --help 89 | ``` 90 | 91 | ## 4단계: 실행 테스트 92 | 93 | ```bash 94 | # 짧은 영상으로 테스트 95 | uv run yt2md "https://www.youtube.com/watch?v=SHORT_VIDEO_ID" 96 | 97 | # 또는 실제 영상으로 98 | uv run yt2md "https://www.youtube.com/watch?v=a1a9wV88MSM" --interval 60 99 | ``` 100 | 101 | ## 빠른 설치 (한 줄 스크립트) 102 | 103 | ### Linux/macOS 104 | 105 | ```bash 106 | # 모든 것을 자동으로 설치 107 | curl -LsSf https://astral.sh/uv/install.sh | sh && \ 108 | sudo apt install -y ffmpeg && \ 109 | git clone https://github.com/practice/yt2md.git && \ 110 | cd yt2md && \ 111 | uv sync 112 | ``` 113 | 114 | ## 설치 후 확인 115 | 116 | ```bash 117 | # 1. uv 확인 118 | uv --version 119 | 120 | # 2. ffmpeg 확인 121 | ffmpeg -version 122 | 123 | # 3. Python 확인 124 | python3 --version 125 | 126 | # 4. yt2md 확인 127 | uv run yt2md --help 128 | ``` 129 | 130 | ## 문제 해결 131 | 132 | ### uv를 찾을 수 없다는 오류 133 | 134 | ```bash 135 | # PATH에 uv 추가 136 | echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.bashrc 137 | source ~/.bashrc 138 | ``` 139 | 140 | ### ffmpeg를 찾을 수 없다는 오류 141 | 142 | ```bash 143 | # ffmpeg 재설치 144 | sudo apt update 145 | sudo apt install ffmpeg 146 | 147 | # 설치 확인 148 | which ffmpeg 149 | ffmpeg -version 150 | ``` 151 | 152 | ### Python 버전이 낮다는 오류 153 | 154 | ```bash 155 | # Python 3.10 이상 설치 156 | sudo apt install python3.11 python3.11-venv 157 | ``` 158 | 159 | ### 권한 오류 160 | 161 | ```bash 162 | # 프로젝트 폴더 권한 확인 163 | chmod -R 755 yt2md/ 164 | ``` 165 | 166 | ## 프로젝트 업데이트 167 | 168 | ```bash 169 | # Git으로 최신 버전 받기 170 | git pull origin main 171 | 172 | # 의존성 업데이트 173 | uv sync 174 | 175 | # 또는 의존성 재설치 176 | rm -rf .venv 177 | uv sync 178 | ``` 179 | 180 | ## 완전 삭제 181 | 182 | ```bash 183 | # 프로젝트 삭제 184 | rm -rf yt2md/ 185 | 186 | # uv 삭제 (선택사항) 187 | rm -rf ~/.cargo/bin/uv 188 | 189 | # ffmpeg 삭제 (선택사항) 190 | sudo apt remove ffmpeg 191 | ``` 192 | 193 | ## 최소 시스템 요구사항 194 | 195 | - **OS**: Linux, macOS, Windows (WSL) 196 | - **RAM**: 최소 2GB (권장 4GB 이상) 197 | - **디스크**: 최소 1GB 여유 공간 198 | - **네트워크**: YouTube 접속 가능한 인터넷 연결 199 | 200 | ## 추가 팁 201 | 202 | ### 전역 설치 (선택사항) 203 | 204 | ```bash 205 | # uv를 사용해 전역으로 설치 206 | cd yt2md 207 | uv pip install -e . 208 | 209 | # 이제 어디서든 실행 가능 210 | yt2md "https://youtube.com/..." 211 | ``` 212 | 213 | ### alias 설정 214 | 215 | ```bash 216 | # ~/.bashrc 또는 ~/.zshrc에 추가 217 | echo 'alias yt2md="cd ~/yt2md && uv run yt2md"' >> ~/.bashrc 218 | source ~/.bashrc 219 | 220 | # 이제 짧게 실행 가능 221 | yt2md "URL" 222 | ``` 223 | 224 | ### 배치 처리 스크립트 225 | 226 | ```bash 227 | # 여러 영상을 한 번에 처리 228 | cat urls.txt | while read url; do 229 | uv run yt2md "$url" 230 | done 231 | ``` 232 | 233 | ## Docker로 실행 (고급) 234 | 235 | 프로젝트에 `Dockerfile`이 있다면: 236 | 237 | ```bash 238 | # Docker 이미지 빌드 239 | docker build -t yt2md . 240 | 241 | # Docker 컨테이너로 실행 242 | docker run -v $(pwd)/output:/app/output yt2md "https://youtube.com/..." 243 | ``` 244 | 245 | ## 지원 246 | 247 | 문제가 발생하면: 248 | 1. [README.md](README.md)의 문제 해결 섹션 확인 249 | 2. [GitHub Issues](https://github.com/practice/yt2md/issues) 검색 250 | 3. 새로운 이슈 생성 251 | -------------------------------------------------------------------------------- /yt2md/transcript.py: -------------------------------------------------------------------------------- 1 | """ 2 | YouTube transcript/subtitle extraction module 3 | """ 4 | 5 | from youtube_transcript_api import YouTubeTranscriptApi 6 | from typing import List, Dict 7 | from itertools import groupby 8 | 9 | 10 | class TranscriptError(Exception): 11 | """Custom exception for transcript-related errors""" 12 | pass 13 | 14 | 15 | def get_transcript(video_id: str, language: str = "ko") -> List[Dict[str, any]]: 16 | """ 17 | Get transcript/subtitles for a YouTube video 18 | Tries manual captions first, then auto-generated captions 19 | 20 | Args: 21 | video_id: YouTube video ID 22 | language: Language code for subtitles (default: 'ko') 23 | 24 | Returns: 25 | List of transcript entries with 'text', 'start', 'duration' keys 26 | 27 | Raises: 28 | TranscriptError: If transcript cannot be retrieved 29 | """ 30 | try: 31 | # Create API instance 32 | api = YouTubeTranscriptApi() 33 | 34 | # List available transcripts 35 | transcript_list = api.list(video_id) 36 | 37 | # Try to find manually created transcript first 38 | try: 39 | transcript = transcript_list.find_manually_created_transcript([language]) 40 | print(f"✅ Found manual transcript in {language}") 41 | return transcript.fetch() 42 | except: 43 | pass 44 | 45 | # Try auto-generated transcript in specified language 46 | try: 47 | transcript = transcript_list.find_generated_transcript([language]) 48 | print(f"✅ Found auto-generated transcript in {language}") 49 | return transcript.fetch() 50 | except: 51 | pass 52 | 53 | # Fallback to English 54 | if language != "en": 55 | print(f"⚠️ {language} subtitles not found, trying English...") 56 | try: 57 | transcript = transcript_list.find_transcript(['en']) 58 | print(f"✅ Found transcript in English") 59 | return transcript.fetch() 60 | except: 61 | pass 62 | 63 | # Try any available transcript 64 | print(f"⚠️ Trying any available transcript...") 65 | for transcript in transcript_list: 66 | try: 67 | print(f"✅ Using {transcript.language} ({transcript.language_code}) - {'Auto-generated' if transcript.is_generated else 'Manual'}") 68 | return transcript.fetch() 69 | except: 70 | continue 71 | 72 | raise TranscriptError("No transcript available for this video") 73 | 74 | except TranscriptError: 75 | raise 76 | except Exception as e: 77 | raise TranscriptError(f"Failed to get transcript: {str(e)}") 78 | 79 | 80 | def group_transcript_by_interval( 81 | transcript: List[Dict[str, any]], interval: int 82 | ) -> Dict[int, List[Dict[str, any]]]: 83 | """ 84 | Group transcript entries by time intervals 85 | 86 | Args: 87 | transcript: List of transcript entries 88 | interval: Time interval in seconds 89 | 90 | Returns: 91 | Dictionary mapping interval timestamps to transcript entries 92 | """ 93 | grouped = {} 94 | 95 | for entry in transcript: 96 | start_time = entry["start"] 97 | # Calculate which interval this entry belongs to 98 | interval_key = int(start_time // interval) * interval 99 | 100 | if interval_key not in grouped: 101 | grouped[interval_key] = [] 102 | 103 | grouped[interval_key].append(entry) 104 | 105 | return grouped 106 | 107 | 108 | def get_text_for_interval( 109 | transcript: List[Dict[str, any]], start: float, end: float 110 | ) -> str: 111 | """ 112 | Get concatenated text for a specific time interval 113 | Removes consecutive duplicate texts to improve readability 114 | 115 | Args: 116 | transcript: List of transcript entries 117 | start: Start time in seconds 118 | end: End time in seconds 119 | 120 | Returns: 121 | Combined text for the interval (with consecutive duplicates removed) 122 | """ 123 | texts = [] 124 | 125 | for entry in transcript: 126 | # Handle both dict and object formats 127 | if isinstance(entry, dict): 128 | entry_start = entry["start"] 129 | entry_duration = entry["duration"] 130 | entry_text = entry["text"] 131 | else: 132 | # Handle FetchedTranscriptSnippet object 133 | entry_start = entry.start 134 | entry_duration = entry.duration 135 | entry_text = entry.text 136 | 137 | entry_end = entry_start + entry_duration 138 | 139 | # Check if entry overlaps with interval 140 | if entry_start < end and entry_end > start: 141 | texts.append(entry_text) 142 | 143 | # Remove consecutive duplicates 144 | if texts: 145 | texts = [key for key, _ in groupby(texts)] 146 | 147 | return " ".join(texts).strip() 148 | -------------------------------------------------------------------------------- /yt2md/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command-line interface for yt2md 3 | """ 4 | 5 | import argparse 6 | import sys 7 | import shutil 8 | from pathlib import Path 9 | from tempfile import TemporaryDirectory 10 | 11 | from .utils import parse_youtube_url, sanitize_filename 12 | from .transcript import get_transcript, TranscriptError 13 | from .video import ( 14 | get_video_info, 15 | download_video, 16 | extract_screenshots, 17 | check_ffmpeg, 18 | VideoError, 19 | ) 20 | from .markdown import generate_markdown 21 | 22 | 23 | def main(): 24 | """Main entry point for the CLI""" 25 | parser = argparse.ArgumentParser( 26 | description="Convert YouTube videos to Markdown with screenshots and subtitles" 27 | ) 28 | parser.add_argument( 29 | "url", 30 | help="YouTube video URL" 31 | ) 32 | parser.add_argument( 33 | "--interval", 34 | type=int, 35 | default=30, 36 | help="Screenshot interval in seconds (default: 30)" 37 | ) 38 | parser.add_argument( 39 | "--output", 40 | "-o", 41 | help="Output markdown file name (default: video title)" 42 | ) 43 | parser.add_argument( 44 | "--lang", 45 | default="ko", 46 | help="Subtitle language code (default: ko)" 47 | ) 48 | parser.add_argument( 49 | "--output-dir", 50 | default="output", 51 | help="Output directory (default: output)" 52 | ) 53 | 54 | args = parser.parse_args() 55 | 56 | try: 57 | # Step 1: Parse YouTube URL 58 | print(f"🔍 Parsing YouTube URL...") 59 | video_id = parse_youtube_url(args.url) 60 | if not video_id: 61 | print(f"❌ Error: Invalid YouTube URL: {args.url}") 62 | return 1 63 | 64 | print(f"✅ Video ID: {video_id}") 65 | 66 | # Step 2: Check ffmpeg 67 | if not check_ffmpeg(): 68 | print("❌ Error: ffmpeg is not installed") 69 | print("Please install ffmpeg:") 70 | print(" Ubuntu/Debian: sudo apt install ffmpeg") 71 | print(" macOS: brew install ffmpeg") 72 | return 1 73 | 74 | # Step 3: Get video info 75 | print(f"\n📹 Fetching video information...") 76 | video_info = get_video_info(video_id) 77 | print(f"✅ Title: {video_info['title']}") 78 | print(f" Duration: {video_info['duration']} seconds") 79 | 80 | # Step 4: Get transcript 81 | print(f"\n📝 Fetching subtitles ({args.lang})...") 82 | transcript = get_transcript(video_id, args.lang) 83 | print(f"✅ Found {len(transcript)} subtitle entries") 84 | 85 | # Step 5: Download video and extract screenshots 86 | print(f"\n📥 Downloading video...") 87 | with TemporaryDirectory() as temp_dir: 88 | temp_path = Path(temp_dir) 89 | video_path = download_video(video_id, temp_path) 90 | print(f"✅ Video downloaded") 91 | 92 | print(f"\n📸 Extracting screenshots (interval: {args.interval}s)...") 93 | screenshots_temp_dir = temp_path / "screenshots" 94 | screenshots = extract_screenshots(video_path, screenshots_temp_dir, args.interval) 95 | print(f"✅ Extracted {len(screenshots)} screenshots") 96 | 97 | # Step 6: Prepare output directory 98 | output_dir = Path(args.output_dir) 99 | output_dir.mkdir(parents=True, exist_ok=True) 100 | 101 | # Determine output filename 102 | if args.output: 103 | output_filename = args.output 104 | if not output_filename.endswith('.md'): 105 | output_filename += '.md' 106 | else: 107 | output_filename = sanitize_filename(video_info['title']) + '.md' 108 | 109 | output_path = output_dir / output_filename 110 | 111 | # Copy screenshots to output directory 112 | screenshots_output_dir = output_dir / "screenshots" 113 | screenshots_output_dir.mkdir(parents=True, exist_ok=True) 114 | 115 | final_screenshots = [] 116 | for timestamp, screenshot_path in screenshots: 117 | dest_path = screenshots_output_dir / screenshot_path.name 118 | shutil.copy2(screenshot_path, dest_path) 119 | final_screenshots.append((timestamp, dest_path)) 120 | 121 | # Step 7: Generate Markdown 122 | print(f"\n📄 Generating Markdown...") 123 | generate_markdown( 124 | video_info, 125 | final_screenshots, 126 | transcript, 127 | video_id, 128 | output_path, 129 | screenshots_output_dir, 130 | ) 131 | 132 | print(f"\n✅ Success! Generated: {output_path}") 133 | print(f" Screenshots: {screenshots_output_dir}/") 134 | 135 | return 0 136 | 137 | except TranscriptError as e: 138 | print(f"\n❌ Transcript Error: {e}") 139 | return 1 140 | except VideoError as e: 141 | print(f"\n❌ Video Error: {e}") 142 | return 1 143 | except Exception as e: 144 | print(f"\n❌ Unexpected Error: {e}") 145 | import traceback 146 | traceback.print_exc() 147 | return 1 148 | 149 | 150 | if __name__ == "__main__": 151 | sys.exit(main()) 152 | -------------------------------------------------------------------------------- /yt2md/video.py: -------------------------------------------------------------------------------- 1 | """ 2 | Video processing and screenshot extraction module 3 | """ 4 | 5 | import os 6 | import subprocess 7 | import yt_dlp 8 | from pathlib import Path 9 | from typing import List, Dict, Tuple 10 | 11 | 12 | class VideoError(Exception): 13 | """Custom exception for video-related errors""" 14 | pass 15 | 16 | 17 | def get_video_info(video_id: str) -> Dict[str, any]: 18 | """ 19 | Get video metadata using yt-dlp 20 | 21 | Args: 22 | video_id: YouTube video ID 23 | 24 | Returns: 25 | Dictionary with video metadata (title, duration, etc.) 26 | 27 | Raises: 28 | VideoError: If video info cannot be retrieved 29 | """ 30 | ydl_opts = { 31 | 'quiet': True, 32 | 'no_warnings': True, 33 | 'skip_download': True, 34 | } 35 | 36 | try: 37 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 38 | info = ydl.extract_info( 39 | f"https://www.youtube.com/watch?v={video_id}", 40 | download=False 41 | ) 42 | return { 43 | 'title': info.get('title', 'Unknown'), 44 | 'duration': info.get('duration', 0), 45 | 'uploader': info.get('uploader', 'Unknown'), 46 | 'upload_date': info.get('upload_date', 'Unknown'), 47 | 'description': info.get('description', ''), 48 | } 49 | except Exception as e: 50 | raise VideoError(f"Failed to get video info: {str(e)}") 51 | 52 | 53 | def download_video(video_id: str, output_dir: Path) -> Path: 54 | """ 55 | Download video to temporary location 56 | 57 | Args: 58 | video_id: YouTube video ID 59 | output_dir: Directory to save video 60 | 61 | Returns: 62 | Path to downloaded video file 63 | 64 | Raises: 65 | VideoError: If download fails 66 | """ 67 | output_dir.mkdir(parents=True, exist_ok=True) 68 | output_path = output_dir / f"{video_id}.mp4" 69 | 70 | ydl_opts = { 71 | 'format': 'best[ext=mp4]/best', 72 | 'outtmpl': str(output_path), 73 | 'quiet': True, 74 | 'no_warnings': True, 75 | } 76 | 77 | try: 78 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 79 | ydl.download([f"https://www.youtube.com/watch?v={video_id}"]) 80 | return output_path 81 | except Exception as e: 82 | raise VideoError(f"Failed to download video: {str(e)}") 83 | 84 | 85 | def check_ffmpeg() -> bool: 86 | """ 87 | Check if ffmpeg is installed and available 88 | 89 | Returns: 90 | True if ffmpeg is available, False otherwise 91 | """ 92 | try: 93 | subprocess.run( 94 | ['ffmpeg', '-version'], 95 | stdout=subprocess.PIPE, 96 | stderr=subprocess.PIPE, 97 | check=True 98 | ) 99 | return True 100 | except (subprocess.CalledProcessError, FileNotFoundError): 101 | return False 102 | 103 | 104 | def extract_screenshots( 105 | video_path: Path, 106 | output_dir: Path, 107 | interval: int = 30 108 | ) -> List[Tuple[int, Path]]: 109 | """ 110 | Extract screenshots from video at regular intervals 111 | 112 | Args: 113 | video_path: Path to video file 114 | output_dir: Directory to save screenshots 115 | interval: Time interval between screenshots in seconds 116 | 117 | Returns: 118 | List of tuples (timestamp, screenshot_path) 119 | 120 | Raises: 121 | VideoError: If ffmpeg is not available or extraction fails 122 | """ 123 | if not check_ffmpeg(): 124 | raise VideoError( 125 | "ffmpeg is not installed. Please install ffmpeg to extract screenshots.\n" 126 | "Install with: sudo apt install ffmpeg (Ubuntu/Debian) or brew install ffmpeg (macOS)" 127 | ) 128 | 129 | output_dir.mkdir(parents=True, exist_ok=True) 130 | 131 | # Get video duration 132 | duration = get_video_duration(video_path) 133 | if duration <= 0: 134 | raise VideoError("Could not determine video duration") 135 | 136 | # Calculate timestamps for screenshots 137 | timestamps = list(range(0, int(duration), interval)) 138 | screenshots = [] 139 | 140 | for i, timestamp in enumerate(timestamps, start=1): 141 | screenshot_path = output_dir / f"screenshot_{i:03d}.png" 142 | 143 | # Use ffmpeg to extract screenshot at specific timestamp 144 | cmd = [ 145 | 'ffmpeg', 146 | '-ss', str(timestamp), 147 | '-i', str(video_path), 148 | '-vframes', '1', 149 | '-q:v', '2', # Quality (2 is high quality) 150 | '-y', # Overwrite output file 151 | str(screenshot_path) 152 | ] 153 | 154 | try: 155 | subprocess.run( 156 | cmd, 157 | stdout=subprocess.PIPE, 158 | stderr=subprocess.PIPE, 159 | check=True 160 | ) 161 | screenshots.append((timestamp, screenshot_path)) 162 | except subprocess.CalledProcessError as e: 163 | print(f"Warning: Failed to extract screenshot at {timestamp}s") 164 | continue 165 | 166 | return screenshots 167 | 168 | 169 | def get_video_duration(video_path: Path) -> float: 170 | """ 171 | Get video duration in seconds using ffprobe 172 | 173 | Args: 174 | video_path: Path to video file 175 | 176 | Returns: 177 | Duration in seconds 178 | """ 179 | cmd = [ 180 | 'ffprobe', 181 | '-v', 'error', 182 | '-show_entries', 'format=duration', 183 | '-of', 'default=noprint_wrappers=1:nokey=1', 184 | str(video_path) 185 | ] 186 | 187 | try: 188 | result = subprocess.run( 189 | cmd, 190 | stdout=subprocess.PIPE, 191 | stderr=subprocess.PIPE, 192 | check=True, 193 | text=True 194 | ) 195 | return float(result.stdout.strip()) 196 | except (subprocess.CalledProcessError, ValueError): 197 | return 0.0 198 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yt2md 2 | 3 | YouTube 영상을 스크린샷과 자막이 포함된 Markdown 파일로 변환하는 커맨드라인 도구 4 | 5 | ## 주요 기능 6 | 7 | - ✅ YouTube 영상 자동 다운로드 8 | - ✅ 일정 간격으로 스크린샷 자동 추출 9 | - ✅ 자막 자동 다운로드 (수동 자막 + 자동 생성 자막 모두 지원) 10 | - ✅ 한국어, 영어 등 다양한 언어 지원 11 | - ✅ 스크린샷과 자막이 결합된 읽기 쉬운 Markdown 생성 12 | - ✅ 영상을 보는 시간을 절약하고 빠르게 내용 파악 가능 13 | 14 | ## 빠른 시작 15 | 16 | ### 1. 사전 요구사항 설치 17 | 18 | ```bash 19 | # uv 설치 20 | curl -LsSf https://astral.sh/uv/install.sh | sh 21 | 22 | # ffmpeg 설치 23 | # Ubuntu/Debian 24 | sudo apt install ffmpeg 25 | 26 | # macOS 27 | brew install ffmpeg 28 | ``` 29 | 30 | ### 2. 프로젝트 실행 31 | 32 | ```bash 33 | # 프로젝트 가져오기 34 | git clone https://github.com/practice/yt2md.git 35 | cd yt2md 36 | 37 | # 의존성 설치 38 | uv sync 39 | 40 | # 바로 실행! 41 | uv run yt2md "https://www.youtube.com/watch?v=VIDEO_ID" 42 | ``` 43 | 44 | > 💡 **다른 머신에서 설치**: [INSTALL.md](INSTALL.md) 참고 45 | 46 | ## 설치 47 | 48 | ### 사전 요구사항 49 | 50 | **1. Python 3.10 이상** 51 | 52 | **2. uv (Python 패키지 관리자)** 53 | ```bash 54 | curl -LsSf https://astral.sh/uv/install.sh | sh 55 | ``` 56 | 57 | **3. ffmpeg (영상 처리용)** 58 | ```bash 59 | # Ubuntu/Debian 60 | sudo apt install ffmpeg 61 | 62 | # macOS 63 | brew install ffmpeg 64 | 65 | # ffmpeg 설치 확인 66 | ffmpeg -version 67 | ``` 68 | 69 | ### 프로젝트 설치 70 | 71 | ```bash 72 | # 저장소 클론 73 | git clone https://github.com/practice/yt2md.git 74 | cd yt2md 75 | 76 | # 의존성 설치 77 | uv sync 78 | ``` 79 | 80 | ## 사용법 81 | 82 | ### 기본 사용 83 | 84 | ```bash 85 | uv run yt2md "https://www.youtube.com/watch?v=VIDEO_ID" 86 | ``` 87 | 88 | ### 실제 사용 예시 89 | 90 | ```bash 91 | # 클로드 코드 팁 영상을 Markdown으로 변환 92 | uv run yt2md "https://www.youtube.com/watch?v=a1a9wV88MSM" 93 | 94 | # 1분 간격으로 스크린샷 추출 95 | uv run yt2md "https://www.youtube.com/watch?v=VIDEO_ID" --interval 60 96 | 97 | # 영어 자막 사용 98 | uv run yt2md "https://www.youtube.com/watch?v=VIDEO_ID" --lang en 99 | 100 | # 출력 파일명 지정 101 | uv run yt2md "https://www.youtube.com/watch?v=VIDEO_ID" --output "my_video.md" 102 | 103 | # 출력 디렉토리 지정 104 | uv run yt2md "https://www.youtube.com/watch?v=VIDEO_ID" --output-dir "my_outputs" 105 | ``` 106 | 107 | ### 모든 옵션 사용 108 | 109 | ```bash 110 | uv run yt2md "https://youtu.be/VIDEO_ID" \ 111 | --interval 45 \ 112 | --lang ko \ 113 | --output "summary.md" \ 114 | --output-dir "videos" 115 | ``` 116 | 117 | ### 사용 가능한 옵션 118 | 119 | | 옵션 | 설명 | 기본값 | 120 | |------|------|--------| 121 | | `url` | YouTube 영상 URL (필수) | - | 122 | | `--interval` | 스크린샷 간격 (초) | 30 | 123 | | `--output`, `-o` | 출력 파일명 | 영상 제목 | 124 | | `--lang` | 자막 언어 코드 (ko, en 등) | ko | 125 | | `--output-dir` | 출력 디렉토리 | output | 126 | 127 | ## 출력 형식 128 | 129 | 프로그램은 다음과 같은 구조로 파일을 생성합니다: 130 | 131 | ``` 132 | output/ 133 | ├── 꼭_알아야하는_클로드_코드_필수_꿀팁_60가지.md 134 | └── screenshots/ 135 | ├── screenshot_001.png 136 | ├── screenshot_002.png 137 | ├── screenshot_003.png 138 | └── ... 139 | ``` 140 | 141 | ### Markdown 파일 예시 142 | 143 | ```markdown 144 | # 꼭 알아야하는 클로드 코드 필수 꿀팁 60가지 145 | 146 | ## Video Information 147 | 148 | - **URL**: https://www.youtube.com/watch?v=a1a9wV88MSM 149 | - **Duration**: 18:40 150 | - **Uploader**: 채널명 151 | - **Upload Date**: 20240101 152 | - **Generated**: 2025-01-05 00:20:15 153 | 154 | --- 155 | 156 | ## Content 157 | 158 | ### [00:00] 159 | 160 | ![Screenshot at 00:00](screenshots/screenshot_001.png) 161 | 162 | 안녕하세요 오늘은 클로드 코드의 필수 기능들을 알아보겠습니다... 163 | 164 | --- 165 | 166 | ### [00:30] 167 | 168 | ![Screenshot at 00:30](screenshots/screenshot_002.png) 169 | 170 | 첫 번째 팁은 바로... 171 | 172 | --- 173 | ``` 174 | 175 | ## 지원하는 자막 형식 176 | 177 | 1. **수동 생성 자막** (우선순위 1) 178 | 2. **YouTube 자동 생성 자막** (우선순위 2) 179 | 3. **언어 폴백**: 지정된 언어 → 영어 → 사용 가능한 모든 언어 180 | 181 | ## 문제 해결 182 | 183 | ### ffmpeg가 설치되지 않았다는 오류 184 | 185 | ```bash 186 | ❌ Error: ffmpeg is not installed 187 | ``` 188 | 189 | **해결방법:** 190 | ```bash 191 | # Ubuntu/Debian 192 | sudo apt update && sudo apt install ffmpeg 193 | 194 | # macOS 195 | brew install ffmpeg 196 | 197 | # 설치 확인 198 | ffmpeg -version 199 | ``` 200 | 201 | ### 자막을 찾을 수 없다는 오류 202 | 203 | ```bash 204 | ❌ Transcript Error: No transcript available for this video 205 | ``` 206 | 207 | **원인:** 208 | - 영상에 자막이 전혀 없음 (수동 자막도, 자동 생성 자막도 없음) 209 | - 자막이 비활성화된 영상 210 | 211 | **해결방법:** 212 | - YouTube에서 자막 사용 가능 여부 확인 213 | - 다른 영상으로 테스트 214 | 215 | ### 영상 다운로드 실패 216 | 217 | ```bash 218 | ❌ Video Error: Failed to download video 219 | ``` 220 | 221 | **원인:** 222 | - 네트워크 연결 문제 223 | - 비공개 영상 224 | - 연령 제한 영상 225 | - 지역 제한 영상 226 | 227 | **해결방법:** 228 | - 인터넷 연결 확인 229 | - 공개 영상으로 테스트 230 | - YouTube에서 영상 재생 가능 여부 확인 231 | 232 | ## 프로젝트 구조 233 | 234 | ``` 235 | yt2md/ 236 | ├── yt2md/ # 메인 패키지 237 | │ ├── __init__.py # 패키지 초기화 238 | │ ├── cli.py # CLI 진입점 및 메인 로직 239 | │ ├── utils.py # URL 파싱, 파일명 정리 등 240 | │ ├── transcript.py # 자막 추출 및 처리 241 | │ ├── video.py # 영상 다운로드 및 스크린샷 추출 242 | │ └── markdown.py # Markdown 파일 생성 243 | ├── pyproject.toml # 프로젝트 설정 및 의존성 244 | ├── uv.lock # 의존성 잠금 파일 245 | ├── README.md # 이 파일 246 | ├── PRD.md # 제품 요구사항 문서 247 | ├── TODO.md # 개발 태스크 리스트 248 | └── CLAUDE.md # 프로젝트 컨텍스트 249 | ``` 250 | 251 | ## 개발 252 | 253 | ### 테스트 254 | 255 | ```bash 256 | # 짧은 영상으로 빠른 테스트 257 | uv run yt2md "https://www.youtube.com/watch?v=SHORT_VIDEO_ID" --interval 10 258 | 259 | # 도움말 확인 260 | uv run yt2md --help 261 | ``` 262 | 263 | ### 의존성 264 | 265 | - `yt-dlp`: YouTube 영상 다운로드 266 | - `youtube-transcript-api`: 자막 추출 267 | - `ffmpeg`: 스크린샷 추출 (시스템 의존성) 268 | 269 | ## 성능 270 | 271 | - **10분 영상 기준**: 약 3-5분 소요 272 | - **스크린샷 간격**: 30초 기본 (조정 가능) 273 | - **디스크 공간**: 영상 크기 + 스크린샷 (약 100-500MB) 274 | 275 | ## 라이선스 276 | 277 | MIT License 278 | 279 | ## 기여 280 | 281 | 이슈와 풀 리퀘스트를 환영합니다! 282 | 283 | ## 제작자 284 | 285 | uv + Python으로 제작된 YouTube to Markdown 변환 도구 286 | 287 | ## 관련 문서 288 | 289 | - [PRD.md](PRD.md) - 제품 요구사항 문서 290 | - [TODO.md](TODO.md) - 개발 태스크 리스트 291 | - [CLAUDE.md](CLAUDE.md) - 프로젝트 배경 및 목적 292 | 293 | ## 자주 묻는 질문 (FAQ) 294 | 295 | **Q: 비공개 영상도 변환할 수 있나요?** 296 | A: 아니요, 공개 영상만 가능합니다. 297 | 298 | **Q: 자막이 없는 영상은 어떻게 하나요?** 299 | A: YouTube 자동 생성 자막이 있다면 사용됩니다. 자막이 전혀 없는 경우 오류가 발생합니다. 300 | 301 | **Q: 여러 영상을 한 번에 변환할 수 있나요?** 302 | A: 현재는 한 번에 하나씩만 가능합니다. 스크립트로 반복 실행하시면 됩니다. 303 | 304 | **Q: 출력된 Markdown 파일을 어디에 쓸 수 있나요?** 305 | A: Obsidian, Notion, GitHub, VS Code 등 모든 Markdown 뷰어에서 사용 가능합니다. 306 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.10" 4 | 5 | [[package]] 6 | name = "certifi" 7 | version = "2025.10.5" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519 } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286 }, 12 | ] 13 | 14 | [[package]] 15 | name = "charset-normalizer" 16 | version = "3.4.4" 17 | source = { registry = "https://pypi.org/simple" } 18 | sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 } 19 | wheels = [ 20 | { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709 }, 21 | { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814 }, 22 | { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467 }, 23 | { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280 }, 24 | { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454 }, 25 | { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609 }, 26 | { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849 }, 27 | { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586 }, 28 | { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290 }, 29 | { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663 }, 30 | { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964 }, 31 | { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064 }, 32 | { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015 }, 33 | { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792 }, 34 | { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198 }, 35 | { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262 }, 36 | { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 }, 37 | { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 }, 38 | { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 }, 39 | { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863 }, 40 | { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837 }, 41 | { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550 }, 42 | { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162 }, 43 | { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019 }, 44 | { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310 }, 45 | { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022 }, 46 | { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383 }, 47 | { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098 }, 48 | { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991 }, 49 | { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456 }, 50 | { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978 }, 51 | { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969 }, 52 | { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 }, 53 | { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 }, 54 | { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 }, 55 | { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 }, 56 | { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 }, 57 | { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 }, 58 | { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 }, 59 | { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 }, 60 | { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 }, 61 | { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 }, 62 | { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 }, 63 | { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 }, 64 | { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 }, 65 | { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 }, 66 | { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 }, 67 | { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 }, 68 | { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 }, 69 | { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 }, 70 | { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 }, 71 | { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 }, 72 | { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 }, 73 | { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 }, 74 | { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 }, 75 | { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 }, 76 | { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 }, 77 | { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 }, 78 | { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 }, 79 | { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 }, 80 | { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 }, 81 | { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 }, 82 | { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 }, 83 | { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 }, 84 | { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 }, 85 | { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 }, 86 | { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 }, 87 | { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 }, 88 | { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 }, 89 | { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 }, 90 | { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 }, 91 | { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 }, 92 | { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 }, 93 | { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 }, 94 | { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 }, 95 | { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 }, 96 | { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 }, 97 | { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 }, 98 | { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 }, 99 | { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 }, 100 | { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 }, 101 | ] 102 | 103 | [[package]] 104 | name = "defusedxml" 105 | version = "0.7.1" 106 | source = { registry = "https://pypi.org/simple" } 107 | sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } 108 | wheels = [ 109 | { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, 110 | ] 111 | 112 | [[package]] 113 | name = "idna" 114 | version = "3.11" 115 | source = { registry = "https://pypi.org/simple" } 116 | sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } 117 | wheels = [ 118 | { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, 119 | ] 120 | 121 | [[package]] 122 | name = "requests" 123 | version = "2.32.5" 124 | source = { registry = "https://pypi.org/simple" } 125 | dependencies = [ 126 | { name = "certifi" }, 127 | { name = "charset-normalizer" }, 128 | { name = "idna" }, 129 | { name = "urllib3" }, 130 | ] 131 | sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } 132 | wheels = [ 133 | { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, 134 | ] 135 | 136 | [[package]] 137 | name = "urllib3" 138 | version = "2.5.0" 139 | source = { registry = "https://pypi.org/simple" } 140 | sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } 141 | wheels = [ 142 | { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, 143 | ] 144 | 145 | [[package]] 146 | name = "youtube-transcript-api" 147 | version = "1.2.3" 148 | source = { registry = "https://pypi.org/simple" } 149 | dependencies = [ 150 | { name = "defusedxml" }, 151 | { name = "requests" }, 152 | ] 153 | sdist = { url = "https://files.pythonhosted.org/packages/87/03/68c69b2d3e282d45cb3c07e5836a9146ff9574cde720570ffc7eb124e56b/youtube_transcript_api-1.2.3.tar.gz", hash = "sha256:76016b71b410b124892c74df24b07b052702cf3c53afb300d0a2c547c0b71b68", size = 469757 } 154 | wheels = [ 155 | { url = "https://files.pythonhosted.org/packages/ef/75/a861661b73d862e323c12af96ecfb237fb4d1433e551183d4172d39d5275/youtube_transcript_api-1.2.3-py3-none-any.whl", hash = "sha256:0c1b32ea5e739f9efde8c42e3d43e67df475185af6f820109607577b83768375", size = 485140 }, 156 | ] 157 | 158 | [[package]] 159 | name = "yt-dlp" 160 | version = "2025.10.22" 161 | source = { registry = "https://pypi.org/simple" } 162 | sdist = { url = "https://files.pythonhosted.org/packages/08/70/cf4bd6c837ab0a709040888caa70d166aa2dfbb5018d1d5c983bf0b50254/yt_dlp-2025.10.22.tar.gz", hash = "sha256:db2d48133222b1d9508c6de757859c24b5cefb9568cf68ccad85dac20b07f77b", size = 3046863 } 163 | wheels = [ 164 | { url = "https://files.pythonhosted.org/packages/cc/2a/fd184bf97d570841aa86b4aeb84aee93e7957a34059dafd4982157c10bff/yt_dlp-2025.10.22-py3-none-any.whl", hash = "sha256:9c803a9598859f91d0d5bd3337f1506ecb40bbe97f6efbe93bc4461fed344fb2", size = 3248983 }, 165 | ] 166 | 167 | [[package]] 168 | name = "yt2md" 169 | version = "0.1.0" 170 | source = { editable = "." } 171 | dependencies = [ 172 | { name = "youtube-transcript-api" }, 173 | { name = "yt-dlp" }, 174 | ] 175 | 176 | [package.metadata] 177 | requires-dist = [ 178 | { name = "youtube-transcript-api", specifier = ">=0.6.0" }, 179 | { name = "yt-dlp", specifier = ">=2024.0.0" }, 180 | ] 181 | --------------------------------------------------------------------------------