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:
@@ -9,7 +9,15 @@
|
|||||||
"Skill(ourdigital-seo-audit)",
|
"Skill(ourdigital-seo-audit)",
|
||||||
"WebFetch(domain:les.josunhotel.com)",
|
"WebFetch(domain:les.josunhotel.com)",
|
||||||
"WebFetch(domain:josunhotel.com)",
|
"WebFetch(domain:josunhotel.com)",
|
||||||
"WebFetch(domain:pagespeed.web.dev)"
|
"WebFetch(domain:pagespeed.web.dev)",
|
||||||
|
"Bash(ln:*)",
|
||||||
|
"Skill(skill-creator)",
|
||||||
|
"Bash(python:*)",
|
||||||
|
"Bash(mv:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git reset:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
233
custom-skills/02-notion-writer/code/CLAUDE.md
Normal file
233
custom-skills/02-notion-writer/code/CLAUDE.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# Notion Writer - Claude Code Skill
|
||||||
|
|
||||||
|
> **Purpose**: Push markdown content to Notion pages or databases
|
||||||
|
> **Platform**: Claude Code (CLI)
|
||||||
|
> **Input**: Markdown files, Notion URLs
|
||||||
|
> **Output**: Content written to Notion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
| Feature | Input | Output |
|
||||||
|
|---------|-------|--------|
|
||||||
|
| Page Content Append | Markdown + Page URL | Appended blocks |
|
||||||
|
| Page Content Replace | Markdown + Page URL | Replaced content |
|
||||||
|
| Database Row Create | Markdown + DB URL + Title | New database row |
|
||||||
|
| Connection Test | API token | Connection status |
|
||||||
|
| Page/DB Info | URL | Metadata |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Create Notion Integration
|
||||||
|
|
||||||
|
1. Go to [Notion Integrations](https://www.notion.so/my-integrations)
|
||||||
|
2. Click "New integration"
|
||||||
|
3. Name it (e.g., "Claude Writer")
|
||||||
|
4. Select workspace
|
||||||
|
5. Copy the "Internal Integration Token"
|
||||||
|
|
||||||
|
### 2. Share Pages/Databases with Integration
|
||||||
|
|
||||||
|
**Important**: You must share each page or database with your integration:
|
||||||
|
1. Open the Notion page/database
|
||||||
|
2. Click "..." menu → "Connections"
|
||||||
|
3. Add your integration
|
||||||
|
|
||||||
|
### 3. Configure Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Project/claude-skills-factory/custom-skills/02-notion-writer/code/scripts
|
||||||
|
|
||||||
|
# Create .env from example
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit and add your token
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` file:
|
||||||
|
```
|
||||||
|
NOTION_API_KEY=secret_your_integration_token
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Activate Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Test Connection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python notion_writer.py --test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Page/Database Info
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Page info
|
||||||
|
python notion_writer.py --page "https://notion.so/My-Page-abc123" --info
|
||||||
|
|
||||||
|
# Database info
|
||||||
|
python notion_writer.py --database "https://notion.so/abc123" --info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Write to Page
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Append content to page
|
||||||
|
python notion_writer.py --page PAGE_URL --file content.md
|
||||||
|
|
||||||
|
# Replace page content
|
||||||
|
python notion_writer.py --page PAGE_URL --file content.md --replace
|
||||||
|
|
||||||
|
# From stdin
|
||||||
|
cat report.md | python notion_writer.py --page PAGE_URL --stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Database Row
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create row with title and content
|
||||||
|
python notion_writer.py --database DB_URL --title "New Entry" --file content.md
|
||||||
|
|
||||||
|
# Title only
|
||||||
|
python notion_writer.py --database DB_URL --title "Empty Entry"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Markdown Support
|
||||||
|
|
||||||
|
### Supported Elements
|
||||||
|
|
||||||
|
| Markdown | Notion Block |
|
||||||
|
|----------|--------------|
|
||||||
|
| `# Heading` | Heading 1 |
|
||||||
|
| `## Heading` | Heading 2 |
|
||||||
|
| `### Heading` | Heading 3 |
|
||||||
|
| `- item` | Bulleted list |
|
||||||
|
| `1. item` | Numbered list |
|
||||||
|
| `- [ ] task` | To-do (unchecked) |
|
||||||
|
| `- [x] task` | To-do (checked) |
|
||||||
|
| `> quote` | Quote |
|
||||||
|
| `` ```code``` `` | Code block |
|
||||||
|
| `---` | Divider |
|
||||||
|
| Paragraphs | Paragraph |
|
||||||
|
|
||||||
|
### Code Block Languages
|
||||||
|
|
||||||
|
Specify language after opening backticks:
|
||||||
|
```markdown
|
||||||
|
```python
|
||||||
|
print("Hello")
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Push SEO Audit Report
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python notion_writer.py \
|
||||||
|
--page "https://notion.so/SEO-Reports-abc123" \
|
||||||
|
--file ~/reports/seo_audit_2025.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Meeting Notes Entry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python notion_writer.py \
|
||||||
|
--database "https://notion.so/Meeting-Notes-abc123" \
|
||||||
|
--title "Weekly Standup - Dec 26" \
|
||||||
|
--file meeting_notes.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pipe from Another Tool
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pipe YouTube video info to Notion
|
||||||
|
python jamie_video_info.py VIDEO_ID --json | \
|
||||||
|
python notion_writer.py --page PAGE_URL --stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Limits
|
||||||
|
|
||||||
|
| Limit | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Blocks per request | 100 |
|
||||||
|
| Text content per block | 2,000 chars |
|
||||||
|
| Requests per second | ~3 |
|
||||||
|
|
||||||
|
The script automatically batches large content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Could not find page"
|
||||||
|
- Ensure page is shared with your integration
|
||||||
|
- Check URL/ID is correct
|
||||||
|
|
||||||
|
### "Invalid token"
|
||||||
|
- Verify NOTION_API_KEY in .env
|
||||||
|
- Token should start with `secret_`
|
||||||
|
|
||||||
|
### "Rate limited"
|
||||||
|
- Wait and retry
|
||||||
|
- Script handles batching but rapid calls may hit limits
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
02-notion-writer/
|
||||||
|
├── code/
|
||||||
|
│ ├── CLAUDE.md # This skill document
|
||||||
|
│ ├── scripts/
|
||||||
|
│ │ ├── notion_writer.py # Main script
|
||||||
|
│ │ ├── venv/ # Python environment
|
||||||
|
│ │ ├── .env # API token (not committed)
|
||||||
|
│ │ └── .env.example # Template
|
||||||
|
│ ├── output/ # For generated content
|
||||||
|
│ └── references/
|
||||||
|
└── desktop/ # Claude Desktop version (future)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate
|
||||||
|
cd ~/Project/claude-skills-factory/custom-skills/02-notion-writer/code/scripts
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Test
|
||||||
|
python notion_writer.py --test
|
||||||
|
|
||||||
|
# Write to page
|
||||||
|
python notion_writer.py -p PAGE_URL -f content.md
|
||||||
|
|
||||||
|
# Replace content
|
||||||
|
python notion_writer.py -p PAGE_URL -f content.md -r
|
||||||
|
|
||||||
|
# Create DB row
|
||||||
|
python notion_writer.py -d DB_URL -t "Title" -f content.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Version 1.0.0 | Claude Code | 2025-12-26*
|
||||||
4
custom-skills/02-notion-writer/code/scripts/.env.example
Normal file
4
custom-skills/02-notion-writer/code/scripts/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Notion API Configuration
|
||||||
|
# Get your integration token from: https://www.notion.so/my-integrations
|
||||||
|
|
||||||
|
NOTION_API_KEY=secret_your_notion_integration_token_here
|
||||||
594
custom-skills/02-notion-writer/code/scripts/notion_writer.py
Normal file
594
custom-skills/02-notion-writer/code/scripts/notion_writer.py
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Notion Writer - Push markdown content to Notion pages or databases.
|
||||||
|
Supports both page content updates and database row creation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from notion_client import Client
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv(Path(__file__).parent / '.env')
|
||||||
|
|
||||||
|
NOTION_TOKEN = os.getenv('NOTION_API_KEY')
|
||||||
|
|
||||||
|
|
||||||
|
def extract_notion_id(url_or_id: str) -> Optional[str]:
|
||||||
|
"""Extract Notion page/database ID from URL or raw ID."""
|
||||||
|
# Already a raw ID (32 chars hex, with or without dashes)
|
||||||
|
clean_id = url_or_id.replace('-', '')
|
||||||
|
if re.match(r'^[a-f0-9]{32}$', clean_id):
|
||||||
|
return clean_id
|
||||||
|
|
||||||
|
# Notion URL patterns
|
||||||
|
patterns = [
|
||||||
|
r'notion\.so/(?:[^/]+/)?([a-f0-9]{32})', # notion.so/workspace/page-id
|
||||||
|
r'notion\.so/(?:[^/]+/)?[^-]+-([a-f0-9]{32})', # notion.so/workspace/Page-Title-id
|
||||||
|
r'notion\.site/(?:[^/]+/)?([a-f0-9]{32})', # public notion.site
|
||||||
|
r'notion\.site/(?:[^/]+/)?[^-]+-([a-f0-9]{32})',
|
||||||
|
r'([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})', # UUID format
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, url_or_id)
|
||||||
|
if match:
|
||||||
|
return match.group(1).replace('-', '')
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_id_with_dashes(raw_id: str) -> str:
|
||||||
|
"""Format 32-char ID to UUID format with dashes."""
|
||||||
|
if len(raw_id) == 32:
|
||||||
|
return f"{raw_id[:8]}-{raw_id[8:12]}-{raw_id[12:16]}-{raw_id[16:20]}-{raw_id[20:]}"
|
||||||
|
return raw_id
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_to_notion_blocks(markdown_text: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Convert markdown text to Notion block objects."""
|
||||||
|
blocks = []
|
||||||
|
lines = markdown_text.split('\n')
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
|
||||||
|
# Skip empty lines
|
||||||
|
if not line.strip():
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
if line.startswith('######'):
|
||||||
|
blocks.append(create_heading_block(line[6:].strip(), 3))
|
||||||
|
elif line.startswith('#####'):
|
||||||
|
blocks.append(create_heading_block(line[5:].strip(), 3))
|
||||||
|
elif line.startswith('####'):
|
||||||
|
blocks.append(create_heading_block(line[4:].strip(), 3))
|
||||||
|
elif line.startswith('###'):
|
||||||
|
blocks.append(create_heading_block(line[3:].strip(), 3))
|
||||||
|
elif line.startswith('##'):
|
||||||
|
blocks.append(create_heading_block(line[2:].strip(), 2))
|
||||||
|
elif line.startswith('#'):
|
||||||
|
blocks.append(create_heading_block(line[1:].strip(), 1))
|
||||||
|
|
||||||
|
# Code blocks
|
||||||
|
elif line.startswith('```'):
|
||||||
|
language = line[3:].strip() or 'plain text'
|
||||||
|
code_lines = []
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and not lines[i].startswith('```'):
|
||||||
|
code_lines.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
blocks.append(create_code_block('\n'.join(code_lines), language))
|
||||||
|
|
||||||
|
# Bullet list
|
||||||
|
elif line.strip().startswith('- ') or line.strip().startswith('* '):
|
||||||
|
text = line.strip()[2:]
|
||||||
|
blocks.append(create_bulleted_list_block(text))
|
||||||
|
|
||||||
|
# Numbered list
|
||||||
|
elif re.match(r'^\d+\.\s', line.strip()):
|
||||||
|
text = re.sub(r'^\d+\.\s', '', line.strip())
|
||||||
|
blocks.append(create_numbered_list_block(text))
|
||||||
|
|
||||||
|
# Checkbox / Todo
|
||||||
|
elif line.strip().startswith('- [ ]'):
|
||||||
|
text = line.strip()[5:].strip()
|
||||||
|
blocks.append(create_todo_block(text, False))
|
||||||
|
elif line.strip().startswith('- [x]') or line.strip().startswith('- [X]'):
|
||||||
|
text = line.strip()[5:].strip()
|
||||||
|
blocks.append(create_todo_block(text, True))
|
||||||
|
|
||||||
|
# Blockquote
|
||||||
|
elif line.startswith('>'):
|
||||||
|
text = line[1:].strip()
|
||||||
|
blocks.append(create_quote_block(text))
|
||||||
|
|
||||||
|
# Horizontal rule
|
||||||
|
elif line.strip() in ['---', '***', '___']:
|
||||||
|
blocks.append(create_divider_block())
|
||||||
|
|
||||||
|
# Regular paragraph
|
||||||
|
else:
|
||||||
|
blocks.append(create_paragraph_block(line))
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rich_text(text: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Parse markdown inline formatting to Notion rich text."""
|
||||||
|
rich_text = []
|
||||||
|
|
||||||
|
# Simple implementation - just plain text for now
|
||||||
|
# TODO: Add support for bold, italic, code, links
|
||||||
|
if text:
|
||||||
|
rich_text.append({
|
||||||
|
"type": "text",
|
||||||
|
"text": {"content": text[:2000]} # Notion limit
|
||||||
|
})
|
||||||
|
|
||||||
|
return rich_text
|
||||||
|
|
||||||
|
|
||||||
|
def create_paragraph_block(text: str) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"object": "block",
|
||||||
|
"type": "paragraph",
|
||||||
|
"paragraph": {
|
||||||
|
"rich_text": parse_rich_text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_heading_block(text: str, level: int) -> Dict[str, Any]:
|
||||||
|
heading_type = f"heading_{level}"
|
||||||
|
return {
|
||||||
|
"object": "block",
|
||||||
|
"type": heading_type,
|
||||||
|
heading_type: {
|
||||||
|
"rich_text": parse_rich_text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_bulleted_list_block(text: str) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"object": "block",
|
||||||
|
"type": "bulleted_list_item",
|
||||||
|
"bulleted_list_item": {
|
||||||
|
"rich_text": parse_rich_text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_numbered_list_block(text: str) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"object": "block",
|
||||||
|
"type": "numbered_list_item",
|
||||||
|
"numbered_list_item": {
|
||||||
|
"rich_text": parse_rich_text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_todo_block(text: str, checked: bool) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"object": "block",
|
||||||
|
"type": "to_do",
|
||||||
|
"to_do": {
|
||||||
|
"rich_text": parse_rich_text(text),
|
||||||
|
"checked": checked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_quote_block(text: str) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"object": "block",
|
||||||
|
"type": "quote",
|
||||||
|
"quote": {
|
||||||
|
"rich_text": parse_rich_text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_code_block(code: str, language: str) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"object": "block",
|
||||||
|
"type": "code",
|
||||||
|
"code": {
|
||||||
|
"rich_text": parse_rich_text(code),
|
||||||
|
"language": language.lower()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_divider_block() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"object": "block",
|
||||||
|
"type": "divider",
|
||||||
|
"divider": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_accessible_content(notion: Client, filter_type: str = 'all') -> None:
|
||||||
|
"""List all accessible pages and databases."""
|
||||||
|
print("=" * 70)
|
||||||
|
print("Accessible Notion Content (Claude-D.intelligence)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
databases = []
|
||||||
|
pages = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Search all content and filter locally
|
||||||
|
response = notion.search()
|
||||||
|
all_results = response.get('results', [])
|
||||||
|
|
||||||
|
# Handle pagination
|
||||||
|
while response.get('has_more'):
|
||||||
|
response = notion.search(start_cursor=response.get('next_cursor'))
|
||||||
|
all_results.extend(response.get('results', []))
|
||||||
|
|
||||||
|
# Separate databases and pages
|
||||||
|
for item in all_results:
|
||||||
|
if item.get('object') == 'database':
|
||||||
|
databases.append(item)
|
||||||
|
elif item.get('object') == 'page':
|
||||||
|
pages.append(item)
|
||||||
|
|
||||||
|
# Show databases
|
||||||
|
if filter_type in ['all', 'database']:
|
||||||
|
print("\n📊 DATABASES")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
if databases:
|
||||||
|
for i, db in enumerate(databases, 1):
|
||||||
|
title = ""
|
||||||
|
if db.get('title'):
|
||||||
|
title = db['title'][0].get('plain_text', 'Untitled') if db['title'] else 'Untitled'
|
||||||
|
db_id = db['id']
|
||||||
|
url = db.get('url', '')
|
||||||
|
props = list(db.get('properties', {}).keys())[:5]
|
||||||
|
print(f"\n{i}. {title}")
|
||||||
|
print(f" ID: {db_id}")
|
||||||
|
print(f" URL: {url}")
|
||||||
|
print(f" Properties: {', '.join(props)}")
|
||||||
|
else:
|
||||||
|
print(" No databases accessible")
|
||||||
|
|
||||||
|
# Show pages
|
||||||
|
if filter_type in ['all', 'page']:
|
||||||
|
print("\n\n📄 PAGES")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
if pages:
|
||||||
|
for i, page in enumerate(pages, 1):
|
||||||
|
title = "Untitled"
|
||||||
|
if 'properties' in page:
|
||||||
|
for prop in page['properties'].values():
|
||||||
|
if prop.get('type') == 'title':
|
||||||
|
title_arr = prop.get('title', [])
|
||||||
|
if title_arr:
|
||||||
|
title = title_arr[0].get('plain_text', 'Untitled')
|
||||||
|
break
|
||||||
|
page_id = page['id']
|
||||||
|
url = page.get('url', '')
|
||||||
|
parent_type = page.get('parent', {}).get('type', 'unknown')
|
||||||
|
print(f"\n{i}. {title}")
|
||||||
|
print(f" ID: {page_id}")
|
||||||
|
print(f" URL: {url}")
|
||||||
|
print(f" Parent: {parent_type}")
|
||||||
|
else:
|
||||||
|
print(" No pages accessible")
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
db_count = len(databases) if filter_type != 'page' else 0
|
||||||
|
page_count = len(pages) if filter_type != 'database' else 0
|
||||||
|
print(f"Total: {db_count} databases, {page_count} pages")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error listing content: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_info(notion: Client, page_id: str) -> Optional[Dict]:
|
||||||
|
"""Get page information."""
|
||||||
|
try:
|
||||||
|
formatted_id = format_id_with_dashes(page_id)
|
||||||
|
return notion.pages.retrieve(page_id=formatted_id)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error retrieving page: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_info(notion: Client, database_id: str) -> Optional[Dict]:
|
||||||
|
"""Get database information."""
|
||||||
|
try:
|
||||||
|
formatted_id = format_id_with_dashes(database_id)
|
||||||
|
return notion.databases.retrieve(database_id=formatted_id)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error retrieving database: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def append_to_page(notion: Client, page_id: str, blocks: List[Dict]) -> bool:
|
||||||
|
"""Append blocks to an existing Notion page."""
|
||||||
|
try:
|
||||||
|
formatted_id = format_id_with_dashes(page_id)
|
||||||
|
|
||||||
|
# Notion API limits to 100 blocks per request
|
||||||
|
for i in range(0, len(blocks), 100):
|
||||||
|
batch = blocks[i:i+100]
|
||||||
|
notion.blocks.children.append(
|
||||||
|
block_id=formatted_id,
|
||||||
|
children=batch
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error appending to page: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clear_page_content(notion: Client, page_id: str) -> bool:
|
||||||
|
"""Clear all content from a page."""
|
||||||
|
try:
|
||||||
|
formatted_id = format_id_with_dashes(page_id)
|
||||||
|
|
||||||
|
# Get all child blocks
|
||||||
|
children = notion.blocks.children.list(block_id=formatted_id)
|
||||||
|
|
||||||
|
# Delete each block
|
||||||
|
for block in children.get('results', []):
|
||||||
|
notion.blocks.delete(block_id=block['id'])
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error clearing page: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_database_row(notion: Client, database_id: str, properties: Dict, content_blocks: List[Dict] = None) -> Optional[str]:
|
||||||
|
"""Create a new row in a Notion database."""
|
||||||
|
try:
|
||||||
|
formatted_id = format_id_with_dashes(database_id)
|
||||||
|
|
||||||
|
page_data = {
|
||||||
|
"parent": {"database_id": formatted_id},
|
||||||
|
"properties": properties
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_blocks:
|
||||||
|
page_data["children"] = content_blocks[:100] # Limit on creation
|
||||||
|
|
||||||
|
result = notion.pages.create(**page_data)
|
||||||
|
|
||||||
|
# If more than 100 blocks, append the rest
|
||||||
|
if content_blocks and len(content_blocks) > 100:
|
||||||
|
append_to_page(notion, result['id'], content_blocks[100:])
|
||||||
|
|
||||||
|
return result['id']
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating database row: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_to_page(notion: Client, page_id: str, markdown_content: str, mode: str = 'append') -> bool:
|
||||||
|
"""Write markdown content to a Notion page."""
|
||||||
|
blocks = markdown_to_notion_blocks(markdown_content)
|
||||||
|
|
||||||
|
if not blocks:
|
||||||
|
print("No content to write")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if mode == 'replace':
|
||||||
|
if not clear_page_content(notion, page_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return append_to_page(notion, page_id, blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Push markdown content to Notion pages or databases',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog='''
|
||||||
|
Examples:
|
||||||
|
# Append markdown file to a page
|
||||||
|
python notion_writer.py --page https://notion.so/My-Page-abc123 --file content.md
|
||||||
|
|
||||||
|
# Replace page content
|
||||||
|
python notion_writer.py --page PAGE_ID --file content.md --replace
|
||||||
|
|
||||||
|
# Pipe content from stdin
|
||||||
|
cat content.md | python notion_writer.py --page PAGE_ID --stdin
|
||||||
|
|
||||||
|
# Create database row with title
|
||||||
|
python notion_writer.py --database DB_URL --title "New Entry" --file content.md
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
python notion_writer.py --test
|
||||||
|
'''
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument('--page', '-p', help='Notion page URL or ID')
|
||||||
|
parser.add_argument('--database', '-d', help='Notion database URL or ID')
|
||||||
|
parser.add_argument('--file', '-f', help='Markdown file to push')
|
||||||
|
parser.add_argument('--stdin', action='store_true', help='Read content from stdin')
|
||||||
|
parser.add_argument('--replace', '-r', action='store_true', help='Replace page content instead of append')
|
||||||
|
parser.add_argument('--title', '-t', help='Title for database row (required for database)')
|
||||||
|
parser.add_argument('--test', action='store_true', help='Test Notion API connection')
|
||||||
|
parser.add_argument('--list', '-l', nargs='?', const='all', choices=['all', 'pages', 'databases'],
|
||||||
|
help='List accessible pages and/or databases (default: all)')
|
||||||
|
parser.add_argument('--info', action='store_true', help='Show page/database info')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not NOTION_TOKEN:
|
||||||
|
print("Error: NOTION_API_KEY not set in .env file")
|
||||||
|
print("Get your integration token from: https://www.notion.so/my-integrations")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
notion = Client(auth=NOTION_TOKEN)
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
if args.test:
|
||||||
|
try:
|
||||||
|
me = notion.users.me()
|
||||||
|
print("=" * 50)
|
||||||
|
print("Notion API Connection Test")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"\n✅ Connected successfully!")
|
||||||
|
print(f" Bot: {me.get('name', 'Unknown')}")
|
||||||
|
print(f" Type: {me.get('type', 'Unknown')}")
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Connection failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# List accessible content
|
||||||
|
if args.list:
|
||||||
|
filter_map = {'all': 'all', 'pages': 'page', 'databases': 'database'}
|
||||||
|
list_accessible_content(notion, filter_map.get(args.list, 'all'))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show info
|
||||||
|
if args.info:
|
||||||
|
if args.page:
|
||||||
|
page_id = extract_notion_id(args.page)
|
||||||
|
if page_id:
|
||||||
|
info = get_page_info(notion, page_id)
|
||||||
|
if info:
|
||||||
|
print("=" * 50)
|
||||||
|
print("Page Information")
|
||||||
|
print("=" * 50)
|
||||||
|
title = ""
|
||||||
|
if 'properties' in info:
|
||||||
|
for prop in info['properties'].values():
|
||||||
|
if prop.get('type') == 'title':
|
||||||
|
title_arr = prop.get('title', [])
|
||||||
|
if title_arr:
|
||||||
|
title = title_arr[0].get('plain_text', '')
|
||||||
|
print(f"Title: {title}")
|
||||||
|
print(f"ID: {info['id']}")
|
||||||
|
print(f"Created: {info.get('created_time', 'N/A')}")
|
||||||
|
print(f"URL: {info.get('url', 'N/A')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.database:
|
||||||
|
db_id = extract_notion_id(args.database)
|
||||||
|
if db_id:
|
||||||
|
info = get_database_info(notion, db_id)
|
||||||
|
if info:
|
||||||
|
print("=" * 50)
|
||||||
|
print("Database Information")
|
||||||
|
print("=" * 50)
|
||||||
|
title = ""
|
||||||
|
if info.get('title'):
|
||||||
|
title = info['title'][0].get('plain_text', '') if info['title'] else ''
|
||||||
|
print(f"Title: {title}")
|
||||||
|
print(f"ID: {info['id']}")
|
||||||
|
print(f"Properties: {', '.join(info.get('properties', {}).keys())}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Please specify --page or --database with --info")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get content
|
||||||
|
content = None
|
||||||
|
if args.stdin:
|
||||||
|
content = sys.stdin.read()
|
||||||
|
elif args.file:
|
||||||
|
file_path = Path(args.file)
|
||||||
|
if not file_path.exists():
|
||||||
|
print(f"Error: File not found: {args.file}")
|
||||||
|
sys.exit(1)
|
||||||
|
content = file_path.read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
# Write to page
|
||||||
|
if args.page:
|
||||||
|
if not content:
|
||||||
|
print("Error: No content provided. Use --file or --stdin")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
page_id = extract_notion_id(args.page)
|
||||||
|
if not page_id:
|
||||||
|
print(f"Error: Invalid Notion page URL/ID: {args.page}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
mode = 'replace' if args.replace else 'append'
|
||||||
|
print(f"{'Replacing' if mode == 'replace' else 'Appending'} content to page...")
|
||||||
|
|
||||||
|
if write_to_page(notion, page_id, content, mode):
|
||||||
|
print(f"✅ Successfully wrote content to page")
|
||||||
|
formatted_id = format_id_with_dashes(page_id)
|
||||||
|
print(f" https://notion.so/{formatted_id.replace('-', '')}")
|
||||||
|
else:
|
||||||
|
print("❌ Failed to write content")
|
||||||
|
sys.exit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create database row
|
||||||
|
if args.database:
|
||||||
|
if not content and not args.title:
|
||||||
|
print("Error: Provide --title and/or --file for database row")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
db_id = extract_notion_id(args.database)
|
||||||
|
if not db_id:
|
||||||
|
print(f"Error: Invalid Notion database URL/ID: {args.database}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Get database schema to find title property
|
||||||
|
db_info = get_database_info(notion, db_id)
|
||||||
|
if not db_info:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Find the title property
|
||||||
|
title_prop = None
|
||||||
|
for prop_name, prop_config in db_info.get('properties', {}).items():
|
||||||
|
if prop_config.get('type') == 'title':
|
||||||
|
title_prop = prop_name
|
||||||
|
break
|
||||||
|
|
||||||
|
if not title_prop:
|
||||||
|
print("Error: Could not find title property in database")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
title_prop: {
|
||||||
|
"title": [{"text": {"content": args.title or "Untitled"}}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content_blocks = markdown_to_notion_blocks(content) if content else None
|
||||||
|
|
||||||
|
print(f"Creating database row...")
|
||||||
|
row_id = create_database_row(notion, db_id, properties, content_blocks)
|
||||||
|
|
||||||
|
if row_id:
|
||||||
|
print(f"✅ Successfully created database row")
|
||||||
|
print(f" https://notion.so/{row_id.replace('-', '')}")
|
||||||
|
else:
|
||||||
|
print("❌ Failed to create database row")
|
||||||
|
sys.exit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# No action specified
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
86
custom-skills/02-notion-writer/desktop/SKILL.md
Normal file
86
custom-skills/02-notion-writer/desktop/SKILL.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
name: notion-writer
|
||||||
|
description: Push markdown content to Notion pages or databases. Supports appending to pages, replacing page content, creating database rows, listing accessible content, and getting page/database info. Use when working with Notion documentation, saving reports to Notion, or managing Notion content programmatically.
|
||||||
|
allowed-tools: Read, Glob, Grep, Write, Edit, Bash
|
||||||
|
---
|
||||||
|
|
||||||
|
# Notion Writer Skill
|
||||||
|
|
||||||
|
Push markdown content to Notion pages or databases via Claude Code.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python virtual environment at `~/Project/claude-skills-factory/custom-skills/02-notion-writer/code/scripts/venv`
|
||||||
|
- Notion API key configured in `.env` file
|
||||||
|
- Target pages/databases must be shared with the integration
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Project/claude-skills-factory/custom-skills/02-notion-writer/code/scripts
|
||||||
|
source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Test Connection
|
||||||
|
```bash
|
||||||
|
python notion_writer.py --test
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Accessible Content
|
||||||
|
```bash
|
||||||
|
python notion_writer.py --list
|
||||||
|
python notion_writer.py --list --filter pages
|
||||||
|
python notion_writer.py --list --filter databases
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Page/Database Info
|
||||||
|
```bash
|
||||||
|
python notion_writer.py -p PAGE_URL --info
|
||||||
|
python notion_writer.py -d DATABASE_URL --info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Write to Page
|
||||||
|
```bash
|
||||||
|
# Append content
|
||||||
|
python notion_writer.py -p PAGE_URL -f content.md
|
||||||
|
|
||||||
|
# Replace content
|
||||||
|
python notion_writer.py -p PAGE_URL -f content.md --replace
|
||||||
|
|
||||||
|
# From stdin
|
||||||
|
cat report.md | python notion_writer.py -p PAGE_URL --stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Database Row
|
||||||
|
```bash
|
||||||
|
python notion_writer.py -d DATABASE_URL -t "Entry Title" -f content.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Markdown
|
||||||
|
|
||||||
|
| Markdown | Notion Block |
|
||||||
|
|----------|--------------|
|
||||||
|
| `# Heading` | Heading 1 |
|
||||||
|
| `## Heading` | Heading 2 |
|
||||||
|
| `### Heading` | Heading 3 |
|
||||||
|
| `- item` | Bulleted list |
|
||||||
|
| `1. item` | Numbered list |
|
||||||
|
| `- [ ] task` | To-do (unchecked) |
|
||||||
|
| `- [x] task` | To-do (checked) |
|
||||||
|
| `> quote` | Quote |
|
||||||
|
| `` ```code``` `` | Code block |
|
||||||
|
| `---` | Divider |
|
||||||
|
| Paragraphs | Paragraph |
|
||||||
|
|
||||||
|
## Workflow Example
|
||||||
|
|
||||||
|
Integrate with Jamie YouTube Manager to log video info:
|
||||||
|
```bash
|
||||||
|
# Check video and save to markdown
|
||||||
|
python jamie_youtube_api_test.py VIDEO_URL
|
||||||
|
|
||||||
|
# Write to Notion
|
||||||
|
python notion_writer.py -p LOG_PAGE_URL -f output/video_status.md
|
||||||
|
```
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
> **Purpose**: YouTube SEO Auditor & Content Manager for Jamie Plastic Surgery Clinic (제이미성형외과)
|
> **Purpose**: YouTube SEO Auditor & Content Manager for Jamie Plastic Surgery Clinic (제이미성형외과)
|
||||||
> **Platform**: Claude Code (CLI)
|
> **Platform**: Claude Code (CLI)
|
||||||
> **Input**: YouTube URLs, video metadata, or exported data
|
> **Input**: YouTube URLs, video metadata, or exported data
|
||||||
> **Output**: Audit reports, optimized metadata, schema markup
|
> **Output**: Audit reports, optimized metadata, schema markup, API batch updates
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,6 +17,114 @@
|
|||||||
| Schema Generation | Video details | JSON-LD markup |
|
| Schema Generation | Video details | JSON-LD markup |
|
||||||
| Description Writing | Video topic | SEO-optimized description |
|
| Description Writing | Video topic | SEO-optimized description |
|
||||||
| Shorts Optimization | Shorts content | Optimization checklist |
|
| Shorts Optimization | Shorts content | Optimization checklist |
|
||||||
|
| **Batch Metadata Update** | T&D document | YouTube API batch update |
|
||||||
|
| **Video Info Fetch** | YouTube URL(s) | Detailed video info + stats |
|
||||||
|
| **API Connection Test** | OAuth credentials | Connection status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## YouTube API Integration
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
1. **Google Cloud Project**: `ourdigital-insights`
|
||||||
|
2. **YouTube Data API v3**: Enabled
|
||||||
|
3. **OAuth Credentials**: Desktop app type
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to scripts directory
|
||||||
|
cd ~/Project/claude-skills-factory/custom-skills/43-jamie-youtube-manager/code/scripts
|
||||||
|
|
||||||
|
# Activate virtual environment
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Required packages (already installed)
|
||||||
|
pip install google-api-python-client google-auth-oauthlib python-dotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Configuration
|
||||||
|
|
||||||
|
`.env` file structure:
|
||||||
|
```
|
||||||
|
GOOGLE_CLIENT_ID=<your-client-id>
|
||||||
|
GOOGLE_CLIENT_SECRET=<your-client-secret>
|
||||||
|
GOOGLE_PROJECT_ID=ourdigital-insights
|
||||||
|
GOOGLE_CLIENT_SECRETS_FILE=/Users/ourdigital/.config/gcloud/keys/jamie-youtube-manager.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Scripts
|
||||||
|
|
||||||
|
#### 1. Connection Test (`jamie_youtube_api_test.py`)
|
||||||
|
|
||||||
|
Tests OAuth authentication and video access:
|
||||||
|
```bash
|
||||||
|
source jamie_youtube_venv/bin/activate
|
||||||
|
python jamie_youtube_api_test.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output**:
|
||||||
|
- Authenticated channel info
|
||||||
|
- Video access verification
|
||||||
|
- Credential status
|
||||||
|
|
||||||
|
#### 2. Video Info (`jamie_video_info.py`)
|
||||||
|
|
||||||
|
Fetches detailed video information from URLs or video IDs:
|
||||||
|
```bash
|
||||||
|
# Single video by URL
|
||||||
|
python jamie_video_info.py https://youtu.be/P-ovr-aaD1E
|
||||||
|
|
||||||
|
# Multiple videos
|
||||||
|
python jamie_video_info.py URL1 URL2 URL3
|
||||||
|
|
||||||
|
# Verbose mode (includes description & tags)
|
||||||
|
python jamie_video_info.py VIDEO_ID -v
|
||||||
|
|
||||||
|
# JSON output
|
||||||
|
python jamie_video_info.py VIDEO_ID --json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output**:
|
||||||
|
- Video title, URL, channel
|
||||||
|
- Published date, privacy status, duration
|
||||||
|
- Statistics (views, likes, comments)
|
||||||
|
- Jamie channel badge (🏥 Jamie vs External)
|
||||||
|
- Description and tags (verbose mode)
|
||||||
|
|
||||||
|
#### 3. Channel Status (`jamie_channel_status.py`)
|
||||||
|
|
||||||
|
Check current status of Jamie YouTube channel and all 18 videos:
|
||||||
|
```bash
|
||||||
|
python jamie_channel_status.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output**:
|
||||||
|
- Channel statistics (subscribers, views, video count)
|
||||||
|
- All 18 video status (title, privacy, views, duration)
|
||||||
|
- Summary by privacy status
|
||||||
|
|
||||||
|
#### 4. Batch Metadata Update (`jamie_youtube_batch_update.py`)
|
||||||
|
|
||||||
|
Updates video titles and descriptions via YouTube API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry-run mode (preview only)
|
||||||
|
python jamie_youtube_batch_update.py --dry-run
|
||||||
|
|
||||||
|
# Execute actual updates
|
||||||
|
python jamie_youtube_batch_update.py --execute
|
||||||
|
|
||||||
|
# Update specific video
|
||||||
|
python jamie_youtube_batch_update.py --execute --video-id VIDEO_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Dry-run mode for safe testing
|
||||||
|
- Batch update all 18 Jamie videos
|
||||||
|
- Common footer auto-appended
|
||||||
|
- OAuth token persistence
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -348,4 +456,83 @@ User: "영어 자막/메타데이터 추천해줘"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Version 1.0.0 | Claude Code | 2025-12*
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
43-jamie-youtube-manager/
|
||||||
|
├── code/
|
||||||
|
│ ├── CLAUDE.md # This file (Claude Code skill)
|
||||||
|
│ ├── scripts/
|
||||||
|
│ │ ├── jamie_youtube_api_test.py # API connection test
|
||||||
|
│ │ ├── jamie_video_info.py # Video info fetcher (URL-based)
|
||||||
|
│ │ ├── jamie_channel_status.py # Channel & video status
|
||||||
|
│ │ ├── jamie_youtube_batch_update.py # Batch metadata updater
|
||||||
|
│ │ ├── jamie_youtube_token.pickle # OAuth token (cached)
|
||||||
|
│ │ ├── venv/ # Python virtual environment
|
||||||
|
│ │ └── .env # Environment variables
|
||||||
|
│ ├── output/
|
||||||
|
│ │ └── jamie_youtube_td_final.md # T&D document (18 videos)
|
||||||
|
│ └── references/
|
||||||
|
│ └── ...
|
||||||
|
└── desktop/
|
||||||
|
├── SKILL.md # Claude Desktop skill
|
||||||
|
└── references/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Video Inventory (18 Videos)
|
||||||
|
|
||||||
|
| No | Video ID | 시술명 | 길이 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | P-ovr-aaD1E | 병원 소개 | 0:33 |
|
||||||
|
| 1 | qZQwAX6Onj0 | 눈 성형 | 1:27 |
|
||||||
|
| 2 | _m6H4F_nLYU | 퀵 매몰법 | 1:28 |
|
||||||
|
| 3 | CBAGAY_b0HU | 하이브리드 쌍꺼풀 | 1:33 |
|
||||||
|
| 4 | TxFajDli1QQ | 안검하수 눈매교정술 | 1:53 |
|
||||||
|
| 5 | Ey5eR4dCi_I | 눈밑지방 재배치 | 1:38 |
|
||||||
|
| 6 | ffUmrE-Ckt0 | 듀얼 트임 수술 | 1:42 |
|
||||||
|
| 7 | 1MA0OJJYcQk | 눈썹밑 피부절개술 | 1:33 |
|
||||||
|
| 8 | UoeOnT1j41Y | 눈 재수술 | 1:59 |
|
||||||
|
| 9 | a7FcFMiGiTs | 이마 성형 | 3:44 |
|
||||||
|
| 10 | lIq816rp4js | 내시경 이마 거상술 | 3:42 |
|
||||||
|
| 11 | EwgtJUH46dc | 내시경 눈썹 거상술 | 3:50 |
|
||||||
|
| 12 | gfbJlqlAIfg | 동안 성형 | 1:51 |
|
||||||
|
| 13 | lRtAatuhcC4 | 동안 시술 | 2:21 |
|
||||||
|
| 14 | 7saghBp2a_A | 앞광대 리프팅 | 1:44 |
|
||||||
|
| 15 | Mq6zcx_8owY | 스마스 리프팅 | 1:56 |
|
||||||
|
| 16 | _bCJDZx2L2I | 자가 지방이식 | 1:47 |
|
||||||
|
| 17 | kXbP1T6ICxY | 하이푸 리프팅 | 1:50 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to scripts directory
|
||||||
|
cd ~/Project/claude-skills-factory/custom-skills/43-jamie-youtube-manager/code/scripts
|
||||||
|
|
||||||
|
# Activate environment
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Test API connection
|
||||||
|
python jamie_youtube_api_test.py
|
||||||
|
|
||||||
|
# Get specific video info from URL
|
||||||
|
python jamie_video_info.py https://youtu.be/VIDEO_ID -v
|
||||||
|
|
||||||
|
# Check channel & video status
|
||||||
|
python jamie_channel_status.py
|
||||||
|
|
||||||
|
# Preview batch update
|
||||||
|
python jamie_youtube_batch_update.py --dry-run
|
||||||
|
|
||||||
|
# Execute batch update
|
||||||
|
python jamie_youtube_batch_update.py --execute
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Version 1.1.0 | Claude Code | 2025-12-26*
|
||||||
|
*Added: YouTube Data API v3 integration, batch metadata update*
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Jamie YouTube Channel Status Check
|
||||||
|
Fetches current status of all Jamie clinic videos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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 video IDs
|
||||||
|
JAMIE_VIDEOS = [
|
||||||
|
"P-ovr-aaD1E", # 병원 소개
|
||||||
|
"qZQwAX6Onj0", # 눈 성형
|
||||||
|
"_m6H4F_nLYU", # 퀵 매몰법
|
||||||
|
"CBAGAY_b0HU", # 하이브리드 쌍꺼풀
|
||||||
|
"TxFajDli1QQ", # 안검하수 눈매교정술
|
||||||
|
"Ey5eR4dCi_I", # 눈밑지방 재배치
|
||||||
|
"ffUmrE-Ckt0", # 듀얼 트임 수술
|
||||||
|
"1MA0OJJYcQk", # 눈썹밑 피부절개술
|
||||||
|
"UoeOnT1j41Y", # 눈 재수술
|
||||||
|
"a7FcFMiGiTs", # 이마 성형
|
||||||
|
"lIq816rp4js", # 내시경 이마 거상술
|
||||||
|
"EwgtJUH46dc", # 내시경 눈썹 거상술
|
||||||
|
"gfbJlqlAIfg", # 동안 성형
|
||||||
|
"lRtAatuhcC4", # 동안 시술
|
||||||
|
"7saghBp2a_A", # 앞광대 리프팅
|
||||||
|
"Mq6zcx_8owY", # 스마스 리프팅
|
||||||
|
"_bCJDZx2L2I", # 자가 지방이식
|
||||||
|
"kXbP1T6ICxY", # 하이푸 리프팅
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 get_channel_info(youtube, channel_id):
|
||||||
|
"""Get channel information."""
|
||||||
|
response = youtube.channels().list(
|
||||||
|
part="snippet,statistics,brandingSettings",
|
||||||
|
id=channel_id
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
if response.get('items'):
|
||||||
|
return response['items'][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_videos_status(youtube, video_ids):
|
||||||
|
"""Get status of multiple videos."""
|
||||||
|
# YouTube API allows max 50 videos per request
|
||||||
|
videos = []
|
||||||
|
for i in range(0, len(video_ids), 50):
|
||||||
|
batch = video_ids[i:i+50]
|
||||||
|
response = youtube.videos().list(
|
||||||
|
part="snippet,status,statistics,contentDetails",
|
||||||
|
id=",".join(batch)
|
||||||
|
).execute()
|
||||||
|
videos.extend(response.get('items', []))
|
||||||
|
return videos
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(duration):
|
||||||
|
"""Convert ISO 8601 duration to readable format."""
|
||||||
|
import re
|
||||||
|
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"{minutes}m")
|
||||||
|
if seconds:
|
||||||
|
parts.append(f"{seconds}s")
|
||||||
|
return " ".join(parts) if parts else "0s"
|
||||||
|
return duration
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print("Jamie Clinic (제이미성형외과) - YouTube Channel Status")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
youtube = get_authenticated_service()
|
||||||
|
|
||||||
|
# Get all Jamie videos
|
||||||
|
videos = get_videos_status(youtube, JAMIE_VIDEOS)
|
||||||
|
|
||||||
|
if not videos:
|
||||||
|
print("\n❌ No videos found or accessible")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get channel info from first video
|
||||||
|
channel_id = videos[0]['snippet']['channelId']
|
||||||
|
channel = get_channel_info(youtube, channel_id)
|
||||||
|
|
||||||
|
if channel:
|
||||||
|
stats = channel.get('statistics', {})
|
||||||
|
print(f"\n📺 Channel: {channel['snippet']['title']}")
|
||||||
|
print(f" ID: {channel_id}")
|
||||||
|
print(f" Subscribers: {stats.get('subscriberCount', 'Hidden')}")
|
||||||
|
print(f" Total Views: {stats.get('viewCount', '0')}")
|
||||||
|
print(f" Total Videos: {stats.get('videoCount', '0')}")
|
||||||
|
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
print(f"{'No':<3} {'Title':<40} {'Status':<10} {'Views':<8} {'Duration'}")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
total_views = 0
|
||||||
|
status_counts = {'public': 0, 'unlisted': 0, 'private': 0}
|
||||||
|
|
||||||
|
for i, video in enumerate(videos):
|
||||||
|
snippet = video['snippet']
|
||||||
|
status = video['status']['privacyStatus']
|
||||||
|
stats = video.get('statistics', {})
|
||||||
|
views = int(stats.get('viewCount', 0))
|
||||||
|
duration = format_duration(video['contentDetails']['duration'])
|
||||||
|
|
||||||
|
title = snippet['title'][:38] + '..' if len(snippet['title']) > 40 else snippet['title']
|
||||||
|
|
||||||
|
status_icon = {'public': '🟢', 'unlisted': '🟡', 'private': '🔴'}.get(status, '⚪')
|
||||||
|
|
||||||
|
print(f"{i+1:<3} {title:<40} {status_icon} {status:<8} {views:<8} {duration}")
|
||||||
|
|
||||||
|
total_views += views
|
||||||
|
status_counts[status] = status_counts.get(status, 0) + 1
|
||||||
|
|
||||||
|
print("-" * 70)
|
||||||
|
print(f"\n📊 Summary")
|
||||||
|
print(f" Total Videos: {len(videos)}")
|
||||||
|
print(f" Total Views: {total_views:,}")
|
||||||
|
print(f" Public: {status_counts.get('public', 0)} | Unlisted: {status_counts.get('unlisted', 0)} | Private: {status_counts.get('private', 0)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
YouTube API Connection Test Script for Jamie Clinic
|
||||||
|
Tests OAuth authentication and verifies Jamie channel access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
# YouTube API scopes needed for updating video metadata
|
||||||
|
SCOPES = ['https://www.googleapis.com/auth/youtube.force-ssl']
|
||||||
|
|
||||||
|
# Token file path
|
||||||
|
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 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:
|
||||||
|
print("Refreshing expired credentials...")
|
||||||
|
creds.refresh(Request())
|
||||||
|
else:
|
||||||
|
if not CLIENT_SECRETS_FILE.exists():
|
||||||
|
print(f"\n[ERROR] OAuth client secret file not found!")
|
||||||
|
print(f"Expected location: {CLIENT_SECRETS_FILE}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
print("Starting OAuth authentication flow...")
|
||||||
|
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)
|
||||||
|
print(f"Credentials saved to: {TOKEN_FILE}")
|
||||||
|
|
||||||
|
return build('youtube', 'v3', credentials=creds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_jamie_channel(youtube):
|
||||||
|
"""Test connection to Jamie's YouTube channel."""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(f"Testing Jamie Channel Access: {JAMIE_CHANNEL_NAME}")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = youtube.channels().list(
|
||||||
|
part="snippet,statistics,brandingSettings",
|
||||||
|
id=JAMIE_CHANNEL_ID
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
if response.get('items'):
|
||||||
|
channel = response['items'][0]
|
||||||
|
stats = channel.get('statistics', {})
|
||||||
|
print(f"\n✅ Jamie Channel Connected!")
|
||||||
|
print(f"\n📺 Channel Info:")
|
||||||
|
print(f" Name: {channel['snippet']['title']}")
|
||||||
|
print(f" ID: {JAMIE_CHANNEL_ID}")
|
||||||
|
print(f" Subscribers: {stats.get('subscriberCount', 'Hidden')}")
|
||||||
|
print(f" Total Views: {stats.get('viewCount', '0')}")
|
||||||
|
print(f" Total Videos: {stats.get('videoCount', '0')}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"\n❌ Jamie channel not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Connection failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_access(youtube, video_id):
|
||||||
|
"""Test if we can access a Jamie video."""
|
||||||
|
print(f"\n" + "="*60)
|
||||||
|
print(f"Testing Video Access: {video_id}")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = youtube.videos().list(
|
||||||
|
part="snippet,status,statistics",
|
||||||
|
id=video_id
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
if response.get('items'):
|
||||||
|
video = response['items'][0]
|
||||||
|
snippet = video['snippet']
|
||||||
|
stats = video.get('statistics', {})
|
||||||
|
|
||||||
|
# Verify it's a Jamie video
|
||||||
|
is_jamie = snippet['channelId'] == JAMIE_CHANNEL_ID
|
||||||
|
|
||||||
|
print(f"\n✅ Video accessible!")
|
||||||
|
print(f" Title: {snippet['title']}")
|
||||||
|
print(f" Channel: {snippet['channelTitle']}")
|
||||||
|
print(f" Status: {video['status']['privacyStatus']}")
|
||||||
|
print(f" Views: {stats.get('viewCount', '0')}")
|
||||||
|
print(f" Jamie Channel: {'✅ Yes' if is_jamie else '❌ No'}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"\n⚠️ Video not found or not accessible")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Video access failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("="*60)
|
||||||
|
print(f"Jamie Clinic ({JAMIE_CHANNEL_NAME}) - API Connection Test")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
youtube = get_authenticated_service()
|
||||||
|
if not youtube:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Test Jamie channel access
|
||||||
|
if not test_jamie_channel(youtube):
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Test access to Jamie's intro video
|
||||||
|
test_video_id = "P-ovr-aaD1E"
|
||||||
|
test_video_access(youtube, test_video_id)
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("API Connection Test Complete!")
|
||||||
|
print("="*60)
|
||||||
|
print("\nAvailable commands:")
|
||||||
|
print(" python jamie_channel_status.py # Full channel status")
|
||||||
|
print(" python jamie_video_info.py <URL> # Get video info")
|
||||||
|
print(" python jamie_youtube_batch_update.py --dry-run")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
YouTube Batch Metadata Update Script for Jamie Clinic
|
||||||
|
Updates video titles and descriptions for all Jamie videos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
# YouTube API scopes
|
||||||
|
SCOPES = ['https://www.googleapis.com/auth/youtube.force-ssl']
|
||||||
|
|
||||||
|
# Token file path
|
||||||
|
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'))
|
||||||
|
|
||||||
|
# Common footer for all video descriptions
|
||||||
|
COMMON_FOOTER = """━━━━━━━━━━━━━━━
|
||||||
|
🏥 제이미 성형외과
|
||||||
|
📍 서울 강남구 압구정로 136 EHL빌딩 3층 (압구정역 5번출구 도보 5분)
|
||||||
|
📞 02-542-2399
|
||||||
|
🌐 https://jamie.clinic
|
||||||
|
📧 info@jamie.clinic
|
||||||
|
━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
⏰ 진료시간
|
||||||
|
월-금 10:00-19:00 | 토 10:00-16:00 | 일·공휴일 휴진
|
||||||
|
(점심시간 13:00-14:00)
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
※ 모든 수술 및 시술은 개인에 따라 출혈, 감염, 염증 등의 부작용이 발생할 수 있으며, 결과에는 개인차가 있을 수 있으므로 의료진과 충분한 상담 후 결정하시기 바랍니다.
|
||||||
|
|
||||||
|
#제이미성형외과 #압구정성형외과 #강남성형외과 #정기호원장"""
|
||||||
|
|
||||||
|
# Video metadata - parsed from jamie_youtube_td_final.md
|
||||||
|
VIDEOS = [
|
||||||
|
{
|
||||||
|
"id": "P-ovr-aaD1E",
|
||||||
|
"title": "제이미 성형외과 소개 | 정기호 원장 인사말",
|
||||||
|
"description": """압구정역 5번출구에 위치한 제이미성형외과를 소개합니다.
|
||||||
|
눈, 이마, 동안 성형 전문 정기호 원장이 직접 인사드립니다.
|
||||||
|
|
||||||
|
📌 제이미 성형외과 전문 분야
|
||||||
|
• 눈 성형 (쌍꺼풀, 눈매교정, 눈밑, 트임 수술)
|
||||||
|
• 이마 성형 (내시경 이마/눈썹 거상술)
|
||||||
|
• 동안 성형 (리프팅, 지방이식)
|
||||||
|
|
||||||
|
#제이미성형외과소개 #압구정성형외과 #정기호원장"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "qZQwAX6Onj0",
|
||||||
|
"title": "눈 성형, 자연스러운 눈매를 만드는 방법 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """제이미 성형외과에서 진행하는 다양한 눈 성형 수술에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 제이미 눈 성형 종류
|
||||||
|
• 퀵 매몰법 - 티 안 나게 자연스러운 쌍꺼풀
|
||||||
|
• 하이브리드 쌍꺼풀 - 절개법+매몰법 장점 결합
|
||||||
|
• 안검하수 눈매교정술 - 졸린 눈을 또렷하게
|
||||||
|
• 눈밑지방 재배치 - 다크서클과 눈밑 꺼짐 개선
|
||||||
|
• 듀얼 트임 수술 - 앞트임+뒤트임으로 시원한 눈매
|
||||||
|
• 눈썹밑 피부절개술 - 처진 눈꺼풀 개선
|
||||||
|
• 눈 재수술 - 이전 수술의 아쉬움 교정
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 자연스러운 쌍꺼풀을 원하시는 분
|
||||||
|
• 눈이 작고 답답해 보이는 분
|
||||||
|
• 눈꺼풀이 처지거나 눈밑이 어두운 분
|
||||||
|
• 이전 눈 수술 결과가 만족스럽지 않은 분
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 눈 성형의 종류
|
||||||
|
0:35 수술별 특징 설명
|
||||||
|
1:10 상담 안내
|
||||||
|
|
||||||
|
#눈성형 #쌍꺼풀수술 #눈매교정 #압구정눈성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "_m6H4F_nLYU",
|
||||||
|
"title": "퀵 매몰법, 티 안 나게 자연스러운 쌍꺼풀 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """휴가를 내지 않고도 자연스러운 쌍꺼풀을 만들 수 있는 퀵 매몰법에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 자연스러운 쌍꺼풀 라인을 원하시는 분
|
||||||
|
• 수술 후 빠른 일상 복귀가 필요하신 분
|
||||||
|
• 절개 없이 쌍꺼풀 수술을 받고 싶으신 분
|
||||||
|
• 붓기와 멍이 적은 수술을 원하시는 분
|
||||||
|
|
||||||
|
📌 제이미 퀵 매몰법 특징
|
||||||
|
• 수술 시간 10-15분
|
||||||
|
• 수면마취 + 국소마취 병행
|
||||||
|
• 수술 다음 날부터 세안, 화장 가능
|
||||||
|
• 실밥 제거 없음
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:08 퀵 매몰법이란?
|
||||||
|
0:30 수술 방법 설명
|
||||||
|
0:55 회복 과정 안내
|
||||||
|
1:15 자주 묻는 질문
|
||||||
|
|
||||||
|
#퀵매몰법 #매몰법 #쌍꺼풀수술 #자연유착 #눈성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CBAGAY_b0HU",
|
||||||
|
"title": "하이브리드 쌍꺼풀, 절개법+매몰법 장점만 결합 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """절개법의 또렷함과 매몰법의 자연스러움을 결합한 하이브리드 쌍꺼풀에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 매몰법으로는 유지가 어려운 두꺼운 눈꺼풀을 가지신 분
|
||||||
|
• 절개법의 흉터가 걱정되시는 분
|
||||||
|
• 또렷하면서도 자연스러운 라인을 원하시는 분
|
||||||
|
• 이전 매몰법 후 풀린 경험이 있으신 분
|
||||||
|
|
||||||
|
📌 하이브리드 쌍꺼풀 특징
|
||||||
|
• 최소 절개로 또렷한 라인 형성
|
||||||
|
• 매몰법보다 유지력 우수
|
||||||
|
• 절개법보다 회복 기간 단축
|
||||||
|
• 개인별 눈 상태에 맞춘 맞춤 디자인
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 하이브리드 쌍꺼풀이란?
|
||||||
|
0:30 매몰법·절개법과의 차이
|
||||||
|
0:55 수술 과정 설명
|
||||||
|
1:20 회복 기간 안내
|
||||||
|
|
||||||
|
#하이브리드쌍꺼풀 #쌍꺼풀수술 #절개법 #매몰법 #눈성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TxFajDli1QQ",
|
||||||
|
"title": "안검하수 눈매교정술, 졸린 눈을 또렷하게 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """졸리고 답답한 눈매를 또렷하고 시원하게 개선하는 안검하수 눈매교정술에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 눈을 떠도 졸려 보인다는 말을 자주 듣는 분
|
||||||
|
• 이마에 힘을 줘야 눈이 떠지는 분
|
||||||
|
• 눈꺼풀이 무겁고 답답하게 느껴지는 분
|
||||||
|
• 쌍꺼풀 수술 후에도 눈이 작아 보이는 분
|
||||||
|
|
||||||
|
📌 안검하수란?
|
||||||
|
눈을 뜨는 근육(눈꺼풀올림근)의 힘이 약해 눈꺼풀이 처지는 현상입니다. 선천적 또는 후천적으로 발생할 수 있으며, 눈매교정술을 통해 개선이 가능합니다.
|
||||||
|
|
||||||
|
📌 제이미 눈매교정술 특징
|
||||||
|
• 정밀한 진단을 통한 개인 맞춤 교정
|
||||||
|
• 자연스러운 눈 뜨는 힘 회복
|
||||||
|
• 쌍꺼풀 수술과 동시 진행 가능
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 안검하수란 무엇인가?
|
||||||
|
0:35 자가 진단 방법
|
||||||
|
1:00 수술 방법 설명
|
||||||
|
1:30 회복 과정 및 주의사항
|
||||||
|
|
||||||
|
#안검하수 #눈매교정 #눈매교정술 #졸린눈 #눈성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Ey5eR4dCi_I",
|
||||||
|
"title": "눈밑지방 재배치, 다크서클과 눈밑 꺼짐 동시 개선 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """어둡고 칙칙한 눈밑을 환하게 개선하는 눈밑지방 재배치에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 눈밑이 불룩하게 튀어나와 보이는 분
|
||||||
|
• 눈밑 다크서클이 심한 분
|
||||||
|
• 눈밑 주름과 그늘로 피곤해 보이는 분
|
||||||
|
• 눈밑 지방 제거 후 꺼짐이 생긴 분
|
||||||
|
|
||||||
|
📌 눈밑지방 재배치란?
|
||||||
|
튀어나온 눈밑 지방을 제거하지 않고, 꺼진 부위로 재배치하여 눈밑을 평탄하고 환하게 만드는 수술입니다. 지방 제거만 하는 것보다 자연스럽고 오래 유지됩니다.
|
||||||
|
|
||||||
|
📌 제이미 눈밑지방 재배치 특징
|
||||||
|
• 결막 절개로 외부 흉터 없음
|
||||||
|
• 지방 재배치로 꺼짐 방지
|
||||||
|
• 다크서클과 눈밑 주름 동시 개선
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 눈밑지방 재배치란?
|
||||||
|
0:30 지방 제거 vs 재배치 차이
|
||||||
|
0:55 수술 과정 설명
|
||||||
|
1:25 회복 기간 안내
|
||||||
|
|
||||||
|
#눈밑지방재배치 #다크서클 #눈밑수술 #눈밑지방 #눈성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ffUmrE-Ckt0",
|
||||||
|
"title": "듀얼 트임 수술, 앞트임+뒤트임으로 시원한 눈매 완성 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """앞트임과 뒤트임을 함께 진행하여 더욱 시원하고 매력적인 눈매를 만드는 듀얼 트임 수술에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 눈이 작고 답답해 보이는 분
|
||||||
|
• 눈 사이 거리가 멀어 보이는 분
|
||||||
|
• 눈꼬리가 올라가 사나운 인상을 주는 분
|
||||||
|
• 쌍꺼풀 수술만으로는 눈이 충분히 커지지 않는 분
|
||||||
|
|
||||||
|
📌 듀얼 트임이란?
|
||||||
|
앞트임(몽고주름 제거)과 뒤트임(눈꼬리 연장)을 함께 진행하여 눈의 가로 길이를 확장하고 눈매 방향을 조절하는 수술입니다.
|
||||||
|
|
||||||
|
📌 제이미 듀얼 트임 특징
|
||||||
|
• 개인별 눈 형태에 맞춘 트임 범위 결정
|
||||||
|
• 흉터 최소화를 위한 정밀 봉합
|
||||||
|
• 쌍꺼풀 수술과 동시 진행 가능
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 듀얼 트임이란?
|
||||||
|
0:30 앞트임·뒤트임 각각의 효과
|
||||||
|
0:55 수술 방법 설명
|
||||||
|
1:25 회복 과정 및 흉터 관리
|
||||||
|
|
||||||
|
#듀얼트임 #앞트임 #뒤트임 #눈트임 #눈성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1MA0OJJYcQk",
|
||||||
|
"title": "눈썹밑 피부절개술, 티 안 나게 처진 눈꺼풀 개선 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """눈썹 아래 절개로 흉터 걱정 없이 처진 눈꺼풀을 개선하는 눈썹밑 피부절개술에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 나이가 들면서 눈꺼풀이 처진 분
|
||||||
|
• 기존 쌍꺼풀 라인은 유지하고 싶은 분
|
||||||
|
• 상안검 수술의 흉터가 걱정되시는 분
|
||||||
|
• 자연스러운 개선을 원하시는 중장년층
|
||||||
|
|
||||||
|
📌 눈썹밑 피부절개술이란?
|
||||||
|
눈썹 바로 아래 피부를 절개하여 처진 눈꺼풀 피부를 제거하는 수술입니다. 절개선이 눈썹 아래에 위치하여 흉터가 거의 눈에 띄지 않습니다.
|
||||||
|
|
||||||
|
📌 제이미 눈썹밑 피부절개술 특징
|
||||||
|
• 눈썹-속눈썹 경계선에 절개로 흉터 최소화
|
||||||
|
• 기존 쌍꺼풀 라인 유지 가능
|
||||||
|
• 이마거상술 대비 회복 기간 단축
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 눈썹밑 피부절개술이란?
|
||||||
|
0:30 상안검 수술과의 차이
|
||||||
|
0:55 수술 방법 설명
|
||||||
|
1:20 회복 과정 안내
|
||||||
|
|
||||||
|
#눈썹밑절개 #눈꺼풀처짐 #상안검 #눈성형 #중년눈성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "UoeOnT1j41Y",
|
||||||
|
"title": "눈 재수술, 이전 수술의 아쉬움을 바로잡는 방법 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """이전 눈 수술 결과가 만족스럽지 않은 분들을 위한 눈 재수술에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 쌍꺼풀이 풀렸거나 비대칭인 분
|
||||||
|
• 쌍꺼풀 라인이 너무 높거나 두꺼운 분
|
||||||
|
• 눈매교정 후 눈이 덜 떠지거나 과교정된 분
|
||||||
|
• 트임 수술 후 흉터나 재유착이 발생한 분
|
||||||
|
|
||||||
|
📌 눈 재수술이 어려운 이유
|
||||||
|
"깨끗한 도화지에 그림을 그리면 화가의 실력이 100% 발휘될 텐데, 재수술은 어느 정도 낙서가 있는 도화지에 덧칠을 하는 것과 같습니다."
|
||||||
|
|
||||||
|
📌 제이미 눈 재수술 특징
|
||||||
|
• 2008년부터 눈 성형을 시행한 풍부한 경험
|
||||||
|
• 이전 수술 상태에 대한 정밀 분석
|
||||||
|
• 현실적인 기대치에 대한 솔직한 상담
|
||||||
|
• 개인별 맞춤 재수술 계획 수립
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 눈 재수술이 필요한 경우
|
||||||
|
0:35 재수술이 어려운 이유
|
||||||
|
1:05 재수술 방법 및 고려사항
|
||||||
|
1:40 상담 시 확인해야 할 점
|
||||||
|
|
||||||
|
#눈재수술 #쌍꺼풀재수술 #눈성형재수술 #눈수술실패 #눈성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a7FcFMiGiTs",
|
||||||
|
"title": "이마 성형, 이마 주름과 처진 눈썹을 개선하는 방법 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """깊어지는 이마 주름과 처진 눈썹으로 고민하시는 분들을 위한 이마 성형에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 제이미 이마 성형 종류
|
||||||
|
• 내시경 이마 거상술 - 이마 주름과 처진 눈꺼풀 동시 개선
|
||||||
|
• 내시경 눈썹 거상술 - 낮은 눈썹과 이마를 젊게
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 이마 주름이 깊어지신 분
|
||||||
|
• 눈썹이 내려앉아 피곤해 보이는 분
|
||||||
|
• 눈꺼풀이 처져 답답한 인상을 주는 분
|
||||||
|
• 보톡스로는 효과가 부족하신 분
|
||||||
|
• 눈썹 위치가 낮아 답답한 인상인 분
|
||||||
|
|
||||||
|
📌 제이미 이마 성형 특징
|
||||||
|
• 두피 내 절개로 흉터 노출 없음
|
||||||
|
• 3점 고정 방식으로 자연스러운 리프팅
|
||||||
|
• 5년 AS 프로그램 운영
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:15 이마 성형이 필요한 경우
|
||||||
|
0:45 내시경 이마 거상술 설명
|
||||||
|
1:30 내시경 눈썹 거상술 설명
|
||||||
|
2:15 수술 방법 비교
|
||||||
|
3:00 회복 과정 및 주의사항
|
||||||
|
3:30 자주 묻는 질문
|
||||||
|
|
||||||
|
#이마성형 #이마거상술 #눈썹거상술 #이마주름 #동안성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lIq816rp4js",
|
||||||
|
"title": "내시경 이마거상술, 이마 주름과 처진 눈꺼풀 동시 개선 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """깊어지는 이마 주름과 처진 눈꺼풀로 고민하시는 분들을 위한 내시경 이마거상술에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 이마 주름이 깊어지신 분
|
||||||
|
• 눈꺼풀이 처져 답답한 인상을 주는 분
|
||||||
|
• 눈썹이 내려앉아 피곤해 보이는 분
|
||||||
|
• 보톡스로는 효과가 부족하신 분
|
||||||
|
|
||||||
|
📌 내시경 이마거상술이란?
|
||||||
|
두피 내 3곳의 최소 절개를 통해 내시경으로 이마 조직을 박리한 후, 처진 이마와 눈썹을 위로 당겨 고정하는 수술입니다.
|
||||||
|
|
||||||
|
📌 제이미 내시경 이마거상술 특징
|
||||||
|
• 3점 고정 방식 - "인형극 실이 많을수록 자연스러운 것처럼"
|
||||||
|
• 흡수성 봉합사 주문 제작 사용
|
||||||
|
• 두피 절개로 흉터 노출 없음
|
||||||
|
• 5년 AS 프로그램 운영
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:15 내시경 이마거상술이란?
|
||||||
|
0:45 3점 고정 방식 설명
|
||||||
|
1:20 수술 과정 안내
|
||||||
|
2:00 회복 기간 및 주의사항
|
||||||
|
2:45 자주 묻는 질문
|
||||||
|
3:25 마무리
|
||||||
|
|
||||||
|
#내시경이마거상술 #이마거상술 #이마주름 #눈썹거상 #동안성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "EwgtJUH46dc",
|
||||||
|
"title": "내시경 눈썹거상술, 낮은 눈썹과 이마를 젊게 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """낮은 이마와 눈썹 때문에 고민하시는 젊은 층을 위한 내시경 눈썹거상술에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 눈썹 위치가 낮아 답답한 인상인 분
|
||||||
|
• 이마가 좁아 보이는 것이 고민인 분
|
||||||
|
• 눈썹과 눈 사이 거리가 좁은 분
|
||||||
|
• 이마거상술보다 가벼운 수술을 원하시는 분
|
||||||
|
|
||||||
|
📌 내시경 눈썹거상술이란?
|
||||||
|
두피 내 절개를 통해 눈썹을 이상적인 위치로 올려주는 수술입니다. 이마거상술보다 회복이 빠르고, 젊은 층의 눈썹 라인 교정에 적합합니다.
|
||||||
|
|
||||||
|
📌 제이미 내시경 눈썹거상술 특징
|
||||||
|
• 눈썹을 이상적인 위치로 리프팅
|
||||||
|
• 이마 라인 개선 효과
|
||||||
|
• 이마거상술 대비 짧은 회복 기간
|
||||||
|
• 자연스러운 눈썹 아치 형성
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:15 내시경 눈썹거상술이란?
|
||||||
|
0:50 이마거상술과의 차이
|
||||||
|
1:30 수술 방법 설명
|
||||||
|
2:15 회복 과정 안내
|
||||||
|
3:00 적합한 대상
|
||||||
|
3:35 마무리
|
||||||
|
|
||||||
|
#내시경눈썹거상술 #눈썹거상술 #눈썹리프팅 #이마성형 #동안성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gfbJlqlAIfg",
|
||||||
|
"title": "동안 성형, 젊고 생기 있는 인상을 만드는 방법 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """처지고 볼륨이 빠진 얼굴을 젊고 생기 있게 개선하는 동안 성형에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 제이미 동안 성형 종류
|
||||||
|
• 앞광대 리프팅 - 눈밑부터 팔자주름까지 한 번에
|
||||||
|
• 스마스 리프팅 - 표정근막층부터 근본적인 안면거상
|
||||||
|
• 자가 지방이식 - 반영구적으로 유지되는 자연스러운 볼륨
|
||||||
|
• 실 리프팅 - 절개 없이 처진 피부를 당기는 방법
|
||||||
|
• 하이푸 리프팅 - 회복 기간 없이 피부 탄력 개선
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 얼굴 전체적인 처짐이 고민인 분
|
||||||
|
• 팔자주름이 깊어지신 분
|
||||||
|
• 볼륨이 빠져 나이 들어 보이는 분
|
||||||
|
• 피부 탄력이 떨어진 분
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 동안 성형의 종류
|
||||||
|
0:40 수술적 방법 vs 비수술적 방법
|
||||||
|
1:15 개인별 맞춤 상담의 중요성
|
||||||
|
1:40 마무리
|
||||||
|
|
||||||
|
#동안성형 #리프팅 #지방이식 #얼굴처짐 #안면거상"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lRtAatuhcC4",
|
||||||
|
"title": "동안 시술, 수술 없이 젊어지는 방법 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """수술 없이 보톡스, 필러, 실 리프팅 등으로 젊어지는 동안 시술에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 제이미 동안 시술 종류
|
||||||
|
|
||||||
|
보톡스
|
||||||
|
• 이마 주름, 미간 주름, 눈가 주름
|
||||||
|
• 사각턱 축소
|
||||||
|
• 효과 지속 기간 약 4개월
|
||||||
|
|
||||||
|
필러
|
||||||
|
• 코 높이 교정
|
||||||
|
• 볼륨 보충 (팔자, 볼, 턱)
|
||||||
|
• 효과 지속 기간 6개월-2년 (제품별 상이)
|
||||||
|
|
||||||
|
실 리프팅
|
||||||
|
• 절개 없이 처진 피부를 당기는 시술
|
||||||
|
• 콜라겐 생성 촉진
|
||||||
|
• 효과 지속 기간 약 1년
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 수술 없이 빠른 개선을 원하시는 분
|
||||||
|
• 특정 부위만 보완하고 싶으신 분
|
||||||
|
• 시술 후 바로 일상 복귀가 필요하신 분
|
||||||
|
• 수술 전 미리 효과를 확인해보고 싶으신 분
|
||||||
|
|
||||||
|
📌 솔직한 조언
|
||||||
|
"쁘띠 시술은 유지 관리가 필요합니다. 반영구적 효과를 원하시면 수술적 방법을 고려해보시기를 권장드립니다."
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:15 보톡스 시술 설명
|
||||||
|
0:45 필러 시술 설명
|
||||||
|
1:15 실 리프팅 설명
|
||||||
|
1:50 시술별 효과 및 지속 기간
|
||||||
|
2:10 마무리
|
||||||
|
|
||||||
|
#동안시술 #쁘띠성형 #보톡스 #필러 #실리프팅"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7saghBp2a_A",
|
||||||
|
"title": "앞광대 리프팅, 눈밑부터 팔자주름까지 한 번에 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """눈밑 꺼짐부터 팔자주름까지 중안면을 한 번에 개선하는 앞광대 리프팅에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 눈밑이 꺼지고 다크서클이 심한 분
|
||||||
|
• 팔자주름이 깊어지신 분
|
||||||
|
• 앞광대가 꺼져 볼륨이 부족한 분
|
||||||
|
• 중안면 전체적인 처짐이 고민인 분
|
||||||
|
|
||||||
|
📌 앞광대 리프팅이란?
|
||||||
|
눈밑부터 앞광대, 팔자주름까지 중안면 전체를 리프팅하여 젊고 생기 있는 인상을 만드는 수술입니다.
|
||||||
|
|
||||||
|
📌 제이미 앞광대 리프팅 특징
|
||||||
|
• 눈밑 꺼짐, 앞광대, 팔자주름 동시 개선
|
||||||
|
• 자연스러운 볼륨 회복
|
||||||
|
• 얼굴 전체 조화를 고려한 수술 계획
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 앞광대 리프팅이란?
|
||||||
|
0:35 개선 가능한 부위 설명
|
||||||
|
1:00 수술 방법 안내
|
||||||
|
1:25 회복 과정 및 효과 지속 기간
|
||||||
|
|
||||||
|
#앞광대리프팅 #중안면리프팅 #팔자주름 #동안성형 #리프팅"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Mq6zcx_8owY",
|
||||||
|
"title": "스마스 리프팅, 표정근막층부터 근본적인 안면거상 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """피부 아래 근막층(SMAS)부터 근본적으로 리프팅하는 스마스 리프팅에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 얼굴 전체적인 처짐이 고민인 분
|
||||||
|
• 실리프팅이나 하이푸로는 효과가 부족하신 분
|
||||||
|
• 오래 지속되는 리프팅을 원하시는 분
|
||||||
|
• 턱선과 목선까지 개선하고 싶으신 분
|
||||||
|
|
||||||
|
📌 스마스 리프팅이란?
|
||||||
|
SMAS(Superficial Musculo-Aponeurotic System)는 피부 아래 근막층으로, 이 층을 함께 당겨주어야 자연스럽고 오래 지속되는 리프팅 효과를 얻을 수 있습니다.
|
||||||
|
|
||||||
|
📌 제이미 스마스 리프팅 특징
|
||||||
|
• 표정 근막층부터 근본적인 리프팅
|
||||||
|
• 5년 이상 효과 지속
|
||||||
|
• 턱선, 목선까지 자연스러운 개선
|
||||||
|
• 개인별 처짐 정도에 맞춘 맞춤 수술
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 스마스(SMAS)층이란?
|
||||||
|
0:35 스마스 리프팅의 원리
|
||||||
|
1:00 수술 방법 설명
|
||||||
|
1:30 회복 과정 및 효과 지속 기간
|
||||||
|
|
||||||
|
#스마스리프팅 #안면거상술 #SMAS리프팅 #얼굴리프팅 #동안성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "_bCJDZx2L2I",
|
||||||
|
"title": "자가 지방이식, 반영구적으로 유지되는 자연스러운 볼륨 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """본인의 지방을 이식하여 반영구적으로 유지되는 자연스러운 볼륨을 만드는 자가 지방이식에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 이마, 볼, 눈밑 등 볼륨이 부족한 분
|
||||||
|
• 필러보다 오래 지속되는 효과를 원하시는 분
|
||||||
|
• 자연스러운 볼륨감을 원하시는 분
|
||||||
|
• 얼굴 전체적인 균형을 맞추고 싶으신 분
|
||||||
|
|
||||||
|
📌 자가 지방이식이란?
|
||||||
|
"나무 옮겨 심는 거랑 똑같다고 하거든요. 한 번 옮겨 심은 나무는 그 자리에서 계속 자라는 거예요."
|
||||||
|
|
||||||
|
본인의 복부, 허벅지 등에서 채취한 지방을 정제하여 볼륨이 필요한 부위에 이식하는 시술입니다.
|
||||||
|
|
||||||
|
📌 제이미 자가 지방이식 특징
|
||||||
|
• 생착률 30-40% 고려한 충분한 이식량
|
||||||
|
• 자연스러운 볼륨 형성
|
||||||
|
• 반영구적 효과 지속
|
||||||
|
• 이마, 볼, 눈밑, 팔자 등 다양한 부위 적용
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 자가 지방이식이란?
|
||||||
|
0:35 지방 생착의 원리
|
||||||
|
1:00 시술 과정 설명
|
||||||
|
1:30 회복 기간 및 관리 방법
|
||||||
|
|
||||||
|
#자가지방이식 #지방이식 #볼륨성형 #이마지방이식 #동안성형"""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kXbP1T6ICxY",
|
||||||
|
"title": "하이푸 리프팅, 회복 기간 없이 피부 탄력 개선 | 제이미 성형외과 정기호 원장",
|
||||||
|
"description": """절개나 주사 없이 초음파로 피부 탄력을 개선하는 하이푸 리프팅에 대해 정기호 원장이 설명해 드립니다.
|
||||||
|
|
||||||
|
📌 이런 분들께 추천드립니다
|
||||||
|
• 시술 후 바로 일상 복귀가 필요하신 분
|
||||||
|
• 절개나 주사가 부담스러우신 분
|
||||||
|
• 피부 탄력이 떨어진 초기 노화 단계인 분
|
||||||
|
• 정기적인 피부 관리를 원하시는 분
|
||||||
|
|
||||||
|
📌 하이푸(HIFU) 리프팅이란?
|
||||||
|
고강도 집속 초음파(High Intensity Focused Ultrasound)를 이용해 피부 깊은 층(SMAS층)에 열 자극을 주어 콜라겐 재생을 촉진하는 시술입니다.
|
||||||
|
|
||||||
|
📌 하이푸 리프팅 특징
|
||||||
|
• 절개, 주사 없는 비침습 시술
|
||||||
|
• 시술 직후 일상생활 가능
|
||||||
|
• 시술 시간 30분-1시간
|
||||||
|
• 효과 지속 기간 3-6개월
|
||||||
|
|
||||||
|
📌 솔직한 조언
|
||||||
|
"하이푸는 유지 관리 개념의 시술입니다. 이미 처짐이 진행된 경우에는 수술적 리프팅이 더 효과적일 수 있습니다."
|
||||||
|
|
||||||
|
⏱️ 챕터
|
||||||
|
0:00 인트로
|
||||||
|
0:10 하이푸(HIFU)란?
|
||||||
|
0:35 작용 원리 설명
|
||||||
|
1:00 시술 과정 안내
|
||||||
|
1:30 효과 및 유지 기간
|
||||||
|
|
||||||
|
#하이푸 #하이푸리프팅 #울쎄라 #리프팅시술 #동안시술"""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
if not CLIENT_SECRETS_FILE.exists():
|
||||||
|
print(f"[ERROR] OAuth client secret file not found: {CLIENT_SECRETS_FILE}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
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 update_video_metadata(youtube, video_id, title, description, dry_run=True):
|
||||||
|
"""Update video title and description."""
|
||||||
|
full_description = description + "\n\n" + COMMON_FOOTER
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f"\n[DRY-RUN] Would update video: {video_id}")
|
||||||
|
print(f" Title: {title[:50]}...")
|
||||||
|
print(f" Description length: {len(full_description)} chars")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First, get current video info to preserve other metadata
|
||||||
|
video_response = youtube.videos().list(
|
||||||
|
part="snippet,status",
|
||||||
|
id=video_id
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
if not video_response.get('items'):
|
||||||
|
print(f"[ERROR] Video not found: {video_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
video = video_response['items'][0]
|
||||||
|
snippet = video['snippet']
|
||||||
|
|
||||||
|
# Update snippet with new title and description
|
||||||
|
snippet['title'] = title
|
||||||
|
snippet['description'] = full_description
|
||||||
|
|
||||||
|
# Execute update
|
||||||
|
youtube.videos().update(
|
||||||
|
part="snippet",
|
||||||
|
body={
|
||||||
|
"id": video_id,
|
||||||
|
"snippet": snippet
|
||||||
|
}
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
print(f"✅ Updated: {video_id} - {title[:40]}...")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to update {video_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description='Update Jamie YouTube video metadata')
|
||||||
|
parser.add_argument('--dry-run', action='store_true', default=True,
|
||||||
|
help='Preview changes without actually updating (default: True)')
|
||||||
|
parser.add_argument('--execute', action='store_true',
|
||||||
|
help='Actually execute the updates')
|
||||||
|
parser.add_argument('--video-id', type=str,
|
||||||
|
help='Update only a specific video ID')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
dry_run = not args.execute
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Jamie Clinic - YouTube Batch Metadata Update")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Mode: {'DRY-RUN (preview only)' if dry_run else 'EXECUTE (will update videos)'}")
|
||||||
|
print(f"Videos to process: {len(VIDEOS)}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
confirm = input("\n⚠️ This will update video metadata. Type 'yes' to confirm: ")
|
||||||
|
if confirm.lower() != 'yes':
|
||||||
|
print("Cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
youtube = get_authenticated_service()
|
||||||
|
if not youtube:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
for video in VIDEOS:
|
||||||
|
if args.video_id and video['id'] != args.video_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = update_video_metadata(
|
||||||
|
youtube,
|
||||||
|
video['id'],
|
||||||
|
video['title'],
|
||||||
|
video['description'],
|
||||||
|
dry_run=dry_run
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Summary")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"{'Would update' if dry_run else 'Updated'}: {success_count} videos")
|
||||||
|
if fail_count > 0:
|
||||||
|
print(f"Failed: {fail_count} videos")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print("\n💡 To execute actual updates, run with --execute flag:")
|
||||||
|
print(" python jamie_youtube_batch_update.py --execute")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,16 +1,58 @@
|
|||||||
---
|
---
|
||||||
name: jamie-youtube-manager
|
name: jamie-youtube-manager
|
||||||
description: |
|
description: |
|
||||||
"YouTube Channel Manager and SEO Auditor for Jamie Plastic Surgery Clinic (제이미성형외과). Audits videos, Shorts, and playlists for SEO optimization including: metadata quality (title, description, tags), chapter timestamps, transcript accuracy, VideoObject schema validation, and internationalization settings. Evaluates video/playlist URLs and provides detailed checklists with enhancement recommendations. Use when auditing Jamie's YouTube content - triggers: "YouTube 감사", "유튜브 SEO", "video audit", "playlist check", "영상 최적화", "챕터 확인", "자막 검토". For content script writing, use jamie-brand-editor.
|
YouTube Channel Manager and SEO Auditor for Jamie Plastic Surgery Clinic (제이미성형외과). Includes CLI scripts for API-based channel/video management and SEO audit capabilities. Check video status, get channel statistics, fetch video info from URLs, batch update metadata, and audit videos for SEO optimization (title, description, tags, timestamps, schema). Use when working with Jamie YouTube content - triggers: "YouTube 감사", "유튜브 SEO", "video audit", "check video", "channel status", "영상 최적화", "챕터 확인".
|
||||||
license: Internal-use Only"
|
allowed-tools: Read, Glob, Grep, Write, Edit, Bash, WebFetch
|
||||||
|
license: Internal-use Only
|
||||||
---
|
---
|
||||||
|
|
||||||
# Jamie YouTube Manager Skill
|
# Jamie YouTube Manager Skill
|
||||||
|
|
||||||
> **Purpose**: YouTube Channel SEO Auditor & Content Manager for Jamie Plastic Surgery Clinic
|
> **Purpose**: YouTube Channel SEO Auditor & Content Manager for Jamie Plastic Surgery Clinic
|
||||||
> **Platform**: Claude Desktop
|
> **Platform**: Claude Code (CLI) + Claude Desktop
|
||||||
> **Input**: YouTube video URLs, playlist URLs, or channel data
|
> **Input**: YouTube video URLs, playlist URLs, or channel data
|
||||||
> **Output**: Detailed audit checklist + SEO enhancement recommendations
|
> **Output**: Video info, channel stats, audit checklist + SEO recommendations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Scripts (Claude Code)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Project/claude-skills-factory/custom-skills/43-jamie-youtube-manager/code/scripts
|
||||||
|
source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Scripts
|
||||||
|
|
||||||
|
| Script | Purpose | Usage |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| `jamie_channel_status.py` | Channel stats overview | `python jamie_channel_status.py` |
|
||||||
|
| `jamie_video_info.py` | Video details from URL | `python jamie_video_info.py "URL"` |
|
||||||
|
| `jamie_youtube_api_test.py` | API connectivity test | `python jamie_youtube_api_test.py` |
|
||||||
|
| `jamie_youtube_batch_update.py` | Batch metadata update | `python jamie_youtube_batch_update.py` |
|
||||||
|
|
||||||
|
### Channel Stats Example
|
||||||
|
```bash
|
||||||
|
python jamie_channel_status.py
|
||||||
|
# Output: Channel name, subscribers, views, recent videos with status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Video Info from URL
|
||||||
|
```bash
|
||||||
|
python jamie_video_info.py "https://youtu.be/VIDEO_ID"
|
||||||
|
# Output: Title, description, duration, views, likes, tags, timestamps, privacy status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with Notion Writer
|
||||||
|
```bash
|
||||||
|
# Save video info to Notion
|
||||||
|
python jamie_video_info.py "URL" > ../output/video_status.md
|
||||||
|
cd ~/Project/claude-skills-factory/custom-skills/02-notion-writer/code/scripts
|
||||||
|
source venv/bin/activate
|
||||||
|
python notion_writer.py -p NOTION_PAGE_URL -f ../../43-jamie-youtube-manager/code/output/video_status.md
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user