feat(skills): Add notion-writer skill and YouTube manager CLI scripts
- 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>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user