- Add 02-notion-writer skill with Python script for pushing markdown to Notion - Add YouTube API CLI scripts for jamie-youtube-manager (channel status, video info, batch update) - Update jamie-youtube-manager SKILL.md with CLI script documentation - Update CLAUDE.md with quick reference guides 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
232 lines
7.3 KiB
Python
232 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Jamie YouTube Video Info Fetcher
|
|
Fetches detailed information for specific YouTube videos from URLs.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import re
|
|
import argparse
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
from dotenv import load_dotenv
|
|
from google.oauth2.credentials import Credentials
|
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
from google.auth.transport.requests import Request
|
|
from googleapiclient.discovery import build
|
|
import pickle
|
|
|
|
# Load environment variables
|
|
load_dotenv(Path(__file__).parent / '.env')
|
|
|
|
SCOPES = ['https://www.googleapis.com/auth/youtube.force-ssl']
|
|
TOKEN_FILE = Path(__file__).parent / 'jamie_youtube_token.pickle'
|
|
CLIENT_SECRETS_FILE = Path(os.getenv('GOOGLE_CLIENT_SECRETS_FILE',
|
|
'/Users/ourdigital/.config/gcloud/keys/jamie-youtube-manager.json'))
|
|
|
|
# Jamie Clinic YouTube Channel (Default)
|
|
JAMIE_CHANNEL_ID = "UCtjR6NnlaX1dPPER7wHbGpw"
|
|
JAMIE_CHANNEL_NAME = "제이미 성형외과"
|
|
|
|
|
|
def extract_video_id(url_or_id):
|
|
"""Extract video ID from various YouTube URL formats."""
|
|
# Already a video ID
|
|
if re.match(r'^[\w-]{11}$', url_or_id):
|
|
return url_or_id
|
|
|
|
# Standard YouTube URLs
|
|
patterns = [
|
|
r'(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/|youtube\.com/v/)([^&\n?#]+)',
|
|
r'youtube\.com/shorts/([^&\n?#]+)',
|
|
]
|
|
|
|
for pattern in patterns:
|
|
match = re.search(pattern, url_or_id)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
return None
|
|
|
|
|
|
def get_authenticated_service():
|
|
"""Authenticate and return YouTube API service."""
|
|
creds = None
|
|
|
|
if TOKEN_FILE.exists():
|
|
with open(TOKEN_FILE, 'rb') as token:
|
|
creds = pickle.load(token)
|
|
|
|
if not creds or not creds.valid:
|
|
if creds and creds.expired and creds.refresh_token:
|
|
creds.refresh(Request())
|
|
else:
|
|
flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRETS_FILE, SCOPES)
|
|
creds = flow.run_local_server(port=8080)
|
|
with open(TOKEN_FILE, 'wb') as token:
|
|
pickle.dump(creds, token)
|
|
|
|
return build('youtube', 'v3', credentials=creds)
|
|
|
|
|
|
def format_duration(duration):
|
|
"""Convert ISO 8601 duration to readable format."""
|
|
match = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', duration)
|
|
if match:
|
|
hours, minutes, seconds = match.groups()
|
|
parts = []
|
|
if hours:
|
|
parts.append(f"{hours}h")
|
|
if minutes:
|
|
parts.append(f"{int(minutes)}m")
|
|
if seconds:
|
|
parts.append(f"{int(seconds)}s")
|
|
return " ".join(parts) if parts else "0s"
|
|
return duration
|
|
|
|
|
|
def format_number(num_str):
|
|
"""Format number with commas."""
|
|
try:
|
|
return f"{int(num_str):,}"
|
|
except:
|
|
return num_str
|
|
|
|
|
|
def get_video_info(youtube, video_id, verbose=False):
|
|
"""Fetch detailed video information."""
|
|
try:
|
|
response = youtube.videos().list(
|
|
part="snippet,status,statistics,contentDetails,topicDetails",
|
|
id=video_id
|
|
).execute()
|
|
|
|
if not response.get('items'):
|
|
return None
|
|
|
|
video = response['items'][0]
|
|
snippet = video['snippet']
|
|
status = video['status']
|
|
stats = video.get('statistics', {})
|
|
content = video['contentDetails']
|
|
|
|
# Check if Jamie video
|
|
is_jamie = snippet['channelId'] == JAMIE_CHANNEL_ID
|
|
|
|
info = {
|
|
'id': video_id,
|
|
'title': snippet['title'],
|
|
'description': snippet['description'],
|
|
'channel': snippet['channelTitle'],
|
|
'channel_id': snippet['channelId'],
|
|
'is_jamie': is_jamie,
|
|
'published_at': snippet['publishedAt'],
|
|
'privacy_status': status['privacyStatus'],
|
|
'duration': format_duration(content['duration']),
|
|
'duration_raw': content['duration'],
|
|
'views': format_number(stats.get('viewCount', '0')),
|
|
'likes': format_number(stats.get('likeCount', '0')),
|
|
'comments': format_number(stats.get('commentCount', '0')),
|
|
'tags': snippet.get('tags', []),
|
|
'category_id': snippet.get('categoryId', ''),
|
|
'thumbnail': snippet.get('thumbnails', {}).get('maxres', {}).get('url') or
|
|
snippet.get('thumbnails', {}).get('high', {}).get('url', ''),
|
|
}
|
|
|
|
return info
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching video info: {e}")
|
|
return None
|
|
|
|
|
|
def print_video_info(info, verbose=False):
|
|
"""Print video information in formatted output."""
|
|
if not info:
|
|
print("❌ Video not found or not accessible")
|
|
return
|
|
|
|
jamie_badge = "🏥 Jamie" if info['is_jamie'] else "External"
|
|
status_icon = {'public': '🟢', 'unlisted': '🟡', 'private': '🔴'}.get(info['privacy_status'], '⚪')
|
|
|
|
print("\n" + "="*70)
|
|
print(f"📹 Video Information")
|
|
print("="*70)
|
|
|
|
print(f"\n🎬 Title: {info['title']}")
|
|
print(f"🔗 URL: https://www.youtube.com/watch?v={info['id']}")
|
|
print(f"📺 Channel: {info['channel']} [{jamie_badge}]")
|
|
print(f"📅 Published: {info['published_at'][:10]}")
|
|
print(f"{status_icon} Status: {info['privacy_status']}")
|
|
print(f"⏱️ Duration: {info['duration']}")
|
|
|
|
print(f"\n📊 Statistics:")
|
|
print(f" Views: {info['views']}")
|
|
print(f" Likes: {info['likes']}")
|
|
print(f" Comments: {info['comments']}")
|
|
|
|
if verbose:
|
|
print(f"\n📝 Description:")
|
|
print("-"*70)
|
|
desc_lines = info['description'].split('\n')[:15]
|
|
for line in desc_lines:
|
|
print(f" {line[:65]}")
|
|
if len(info['description'].split('\n')) > 15:
|
|
print(" ...")
|
|
print("-"*70)
|
|
|
|
if info['tags']:
|
|
print(f"\n🏷️ Tags ({len(info['tags'])}):")
|
|
print(f" {', '.join(info['tags'][:10])}")
|
|
if len(info['tags']) > 10:
|
|
print(f" ... and {len(info['tags']) - 10} more")
|
|
|
|
print(f"\n🖼️ Thumbnail: {info['thumbnail']}")
|
|
|
|
print("\n" + "="*70)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='Fetch YouTube video information',
|
|
epilog='Examples:\n'
|
|
' python jamie_video_info.py https://youtu.be/P-ovr-aaD1E\n'
|
|
' python jamie_video_info.py P-ovr-aaD1E -v\n'
|
|
' python jamie_video_info.py URL1 URL2 URL3',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
parser.add_argument('urls', nargs='+', help='YouTube URL(s) or video ID(s)')
|
|
parser.add_argument('-v', '--verbose', action='store_true', help='Show detailed info including description and tags')
|
|
parser.add_argument('--json', action='store_true', help='Output as JSON')
|
|
|
|
args = parser.parse_args()
|
|
|
|
youtube = get_authenticated_service()
|
|
if not youtube:
|
|
sys.exit(1)
|
|
|
|
results = []
|
|
|
|
for url in args.urls:
|
|
video_id = extract_video_id(url)
|
|
if not video_id:
|
|
print(f"\n❌ Invalid URL or video ID: {url}")
|
|
continue
|
|
|
|
info = get_video_info(youtube, video_id, args.verbose)
|
|
|
|
if args.json:
|
|
results.append(info)
|
|
else:
|
|
print_video_info(info, args.verbose)
|
|
|
|
if args.json:
|
|
import json
|
|
print(json.dumps(results, indent=2, ensure_ascii=False))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|