refactor: Reorganize skill numbering and update documentation

Skill Numbering Changes:
- 01-03: OurDigital core (was 30-32)
- 31-32: Notion tools (was 01-02)
- 99_archive: Renamed from _archive for sorting

New Files:
- AGENTS.md: Claude Code agent routing guide
- requirements.txt for 00-claude-code-setting, 32-notion-writer, 43-jamie-youtube-manager

Documentation Updates:
- CLAUDE.md: Updated skill inventory (23 skills)
- AUDIT_REPORT.md: Current completion status (91%)
- Archived REFACTORING_PLAN.md (most tasks complete)

Removed:
- ga-agent-skills/ (moved to separate repo ~/Project/dintel-ga4-agent)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 18:42:39 +07:00
parent ae193d5e08
commit b69e4b6f3a
100 changed files with 655 additions and 1812 deletions

View 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()