Base code

This commit is contained in:
Kunthawat Greethong
2026-01-08 22:39:53 +07:00
parent 697115c61a
commit c35fa52117
2169 changed files with 626670 additions and 0 deletions

View File

View File

@@ -0,0 +1,170 @@
# WordPress Integration Service
A comprehensive WordPress integration service for ALwrity that enables seamless content publishing to WordPress sites.
## Architecture
### Core Components
1. **WordPressService** (`wordpress_service.py`)
- Manages WordPress site connections
- Handles site credentials and authentication
- Provides site management operations
2. **WordPressContentManager** (`wordpress_content.py`)
- Manages WordPress content operations
- Handles media uploads and compression
- Manages categories, tags, and posts
- Provides WordPress REST API interactions
3. **WordPressPublisher** (`wordpress_publisher.py`)
- High-level publishing service
- Orchestrates content creation and publishing
- Manages post references and tracking
## Features
### Site Management
- ✅ Connect multiple WordPress sites
- ✅ Site credential management
- ✅ Connection testing and validation
- ✅ Site disconnection
### Content Publishing
- ✅ Blog post creation and publishing
- ✅ Media upload with compression
- ✅ Category and tag management
- ✅ Featured image support
- ✅ SEO metadata (meta descriptions)
- ✅ Draft and published status control
### Advanced Features
- ✅ Image compression for better performance
- ✅ Automatic category/tag creation
- ✅ Post status management
- ✅ Post deletion and updates
- ✅ Publishing history tracking
## Database Schema
### WordPress Sites Table
```sql
CREATE TABLE wordpress_sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
site_url TEXT NOT NULL,
site_name TEXT,
username TEXT NOT NULL,
app_password TEXT NOT NULL,
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, site_url)
);
```
### WordPress Posts Table
```sql
CREATE TABLE wordpress_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
site_id INTEGER NOT NULL,
wp_post_id INTEGER NOT NULL,
title TEXT NOT NULL,
status TEXT DEFAULT 'draft',
published_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (site_id) REFERENCES wordpress_sites (id)
);
```
## Usage Examples
### Basic Site Connection
```python
from backend.services.integrations import WordPressService
wp_service = WordPressService()
success = wp_service.add_site(
user_id="user123",
site_url="https://mysite.com",
site_name="My Blog",
username="admin",
app_password="xxxx-xxxx-xxxx-xxxx"
)
```
### Publishing Content
```python
from backend.services.integrations import WordPressPublisher
publisher = WordPressPublisher()
result = publisher.publish_blog_post(
user_id="user123",
site_id=1,
title="My Blog Post",
content="<p>This is my blog post content.</p>",
excerpt="A brief excerpt",
featured_image_path="/path/to/image.jpg",
categories=["Technology", "AI"],
tags=["wordpress", "automation"],
status="publish"
)
```
### Content Management
```python
from backend.services.integrations import WordPressContentManager
content_manager = WordPressContentManager(
site_url="https://mysite.com",
username="admin",
app_password="xxxx-xxxx-xxxx-xxxx"
)
# Upload media
media = content_manager.upload_media(
file_path="/path/to/image.jpg",
alt_text="Description",
title="Image Title"
)
# Create post
post = content_manager.create_post(
title="Post Title",
content="<p>Post content</p>",
featured_media_id=media['id'],
status="draft"
)
```
## Authentication
WordPress integration uses **Application Passwords** for authentication:
1. Go to WordPress Admin → Users → Profile
2. Scroll down to "Application Passwords"
3. Create a new application password
4. Use the generated password for authentication
## Error Handling
All services include comprehensive error handling:
- Connection validation
- API response checking
- Graceful failure handling
- Detailed logging
## Logging
The service uses structured logging with different levels:
- `INFO`: Successful operations
- `WARNING`: Non-critical issues
- `ERROR`: Failed operations
## Security
- Credentials are stored securely in the database
- Application passwords are used instead of main passwords
- Connection testing before credential storage
- Proper authentication for all API calls

View File

@@ -0,0 +1,13 @@
"""
WordPress Integration Package
"""
from .wordpress_service import WordPressService
from .wordpress_content import WordPressContentManager
from .wordpress_publisher import WordPressPublisher
__all__ = [
'WordPressService',
'WordPressContentManager',
'WordPressPublisher'
]

View File

@@ -0,0 +1,925 @@
"""
Bing Webmaster OAuth2 Service
Handles Bing Webmaster Tools OAuth2 authentication flow for SEO analytics access.
"""
import os
import secrets
import sqlite3
import requests
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from loguru import logger
import json
from urllib.parse import quote
from ..analytics_cache_service import analytics_cache
class BingOAuthService:
"""Manages Bing Webmaster Tools OAuth2 authentication flow."""
def __init__(self, db_path: str = "alwrity.db"):
self.db_path = db_path
# Bing Webmaster OAuth2 credentials
self.client_id = os.getenv('BING_CLIENT_ID', '')
self.client_secret = os.getenv('BING_CLIENT_SECRET', '')
self.redirect_uri = os.getenv('BING_REDIRECT_URI', 'https://littery-sonny-unscrutinisingly.ngrok-free.dev/bing/callback')
self.base_url = "https://www.bing.com"
self.api_base_url = "https://www.bing.com/webmaster/api.svc/json"
# Validate configuration
if not self.client_id or not self.client_secret or self.client_id == 'your_bing_client_id_here':
logger.error("Bing Webmaster OAuth client credentials not configured. Please set BING_CLIENT_ID and BING_CLIENT_SECRET environment variables with valid Bing Webmaster application credentials.")
logger.error("To get credentials: 1. Go to https://www.bing.com/webmasters/ 2. Sign in to Bing Webmaster Tools 3. Go to Settings > API Access 4. Create OAuth client")
self._init_db()
def _init_db(self):
"""Initialize database tables for OAuth tokens."""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS bing_oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT,
token_type TEXT DEFAULT 'bearer',
expires_at TIMESTAMP,
scope TEXT,
site_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS bing_oauth_states (
id INTEGER PRIMARY KEY AUTOINCREMENT,
state TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP DEFAULT (datetime('now', '+20 minutes'))
)
''')
conn.commit()
logger.info("Bing Webmaster OAuth database initialized.")
def generate_authorization_url(self, user_id: str, scope: str = "webmaster.manage") -> Dict[str, Any]:
"""Generate Bing Webmaster OAuth2 authorization URL."""
try:
# Check if credentials are properly configured
if not self.client_id or not self.client_secret or self.client_id == 'your_bing_client_id_here':
logger.error("Bing Webmaster OAuth client credentials not configured")
return None
# Generate secure state parameter
state = secrets.token_urlsafe(32)
# Store state in database for validation
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO bing_oauth_states (state, user_id, expires_at)
VALUES (?, ?, datetime('now', '+20 minutes'))
''', (state, user_id))
conn.commit()
# Build authorization URL with proper URL encoding
params = [
f"response_type=code",
f"client_id={self.client_id}",
f"redirect_uri={quote(self.redirect_uri, safe='')}",
f"scope={scope}",
f"state={state}"
]
auth_url = f"{self.base_url}/webmasters/OAuth/authorize?{'&'.join(params)}"
logger.info(f"Generated Bing Webmaster OAuth URL for user {user_id}")
logger.info(f"Bing OAuth redirect URI: {self.redirect_uri}")
return {
"auth_url": auth_url,
"state": state
}
except Exception as e:
logger.error(f"Error generating Bing Webmaster OAuth URL: {e}")
return None
def handle_oauth_callback(self, code: str, state: str) -> Optional[Dict[str, Any]]:
"""Handle OAuth callback and exchange code for access token."""
try:
logger.info(f"Bing Webmaster OAuth callback started - code: {code[:20]}..., state: {state[:20]}...")
# Validate state parameter
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# First, look up the state regardless of expiry to provide clearer logs
cursor.execute('''
SELECT user_id, created_at, expires_at FROM bing_oauth_states
WHERE state = ?
''', (state,))
row = cursor.fetchone()
if not row:
# State not found - likely already consumed (deleted) or never issued
logger.error(f"Bing OAuth: State not found or already used. state='{state[:12]}...'")
return None
user_id, created_at, expires_at = row
# Check expiry explicitly
cursor.execute("SELECT datetime('now') < ?", (expires_at,))
not_expired = cursor.fetchone()[0] == 1
if not not_expired:
logger.error(
f"Bing OAuth: State expired. state='{state[:12]}...', user_id='{user_id}', "
f"created_at='{created_at}', expires_at='{expires_at}'"
)
# Clean up expired state
cursor.execute('DELETE FROM bing_oauth_states WHERE state = ?', (state,))
conn.commit()
return None
# Valid, not expired
logger.info(f"Bing OAuth: State validated for user {user_id}")
# Clean up used state
cursor.execute('DELETE FROM bing_oauth_states WHERE state = ?', (state,))
conn.commit()
# Exchange authorization code for access token
token_data = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': self.redirect_uri
}
logger.info(f"Bing OAuth: Exchanging code for token...")
response = requests.post(
f"{self.base_url}/webmasters/oauth/token",
data=token_data,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
timeout=30
)
if response.status_code != 200:
logger.error(f"Token exchange failed: {response.status_code} - {response.text}")
return None
token_info = response.json()
logger.info(f"Bing OAuth: Token received - expires_in: {token_info.get('expires_in')}")
# Store token information
access_token = token_info.get('access_token')
refresh_token = token_info.get('refresh_token')
expires_in = token_info.get('expires_in', 3600) # Default 1 hour
token_type = token_info.get('token_type', 'bearer')
# Calculate expiration
expires_at = datetime.now() + timedelta(seconds=expires_in)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO bing_oauth_tokens
(user_id, access_token, refresh_token, token_type, expires_at, scope)
VALUES (?, ?, ?, ?, ?, ?)
''', (user_id, access_token, refresh_token, token_type, expires_at, 'webmaster.manage'))
conn.commit()
logger.info(f"Bing OAuth: Token inserted into database for user {user_id}")
# Proactively fetch and cache user sites using the fresh token
try:
headers = {'Authorization': f'Bearer {access_token}'}
response = requests.get(
f"{self.api_base_url}/GetUserSites",
headers={
**headers,
'Origin': 'https://www.bing.com',
'Referer': 'https://www.bing.com/webmasters/'
},
timeout=15
)
sites = []
if response.status_code == 200:
sites_data = response.json()
if isinstance(sites_data, dict):
if 'd' in sites_data:
d_data = sites_data['d']
if isinstance(d_data, dict) and 'results' in d_data:
sites = d_data['results']
elif isinstance(d_data, list):
sites = d_data
elif isinstance(sites_data, list):
sites = sites_data
if sites:
analytics_cache.set('bing_sites', user_id, sites, ttl_override=2*60*60)
logger.info(f"Bing OAuth: Cached {len(sites)} sites for user {user_id} after OAuth callback")
except Exception as site_err:
logger.warning(f"Bing OAuth: Failed to prefetch sites after OAuth callback: {site_err}")
# Invalidate platform status and sites cache since connection status changed
# Don't invalidate analytics data cache as it's expensive to regenerate
analytics_cache.invalidate('platform_status', user_id)
analytics_cache.invalidate('bing_sites', user_id)
logger.info(f"Bing OAuth: Invalidated platform status and sites cache for user {user_id} due to new connection")
logger.info(f"Bing Webmaster OAuth token stored successfully for user {user_id}")
return {
"success": True,
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": token_type,
"expires_in": expires_in,
"expires_at": expires_at.isoformat()
}
except Exception as e:
logger.error(f"Error handling Bing Webmaster OAuth callback: {e}")
return None
def purge_expired_tokens(self, user_id: str) -> int:
"""Delete expired or inactive Bing tokens for a user to avoid refresh loops.
Returns number of rows deleted.
"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Delete tokens that are expired or explicitly inactive
cursor.execute('''
DELETE FROM bing_oauth_tokens
WHERE user_id = ? AND (is_active = FALSE OR (expires_at IS NOT NULL AND expires_at <= datetime('now')))
''', (user_id,))
deleted = cursor.rowcount or 0
conn.commit()
if deleted > 0:
logger.info(f"Bing OAuth: Purged {deleted} expired/inactive tokens for user {user_id}")
else:
logger.info(f"Bing OAuth: No expired/inactive tokens to purge for user {user_id}")
# Invalidate platform status cache so UI updates
analytics_cache.invalidate('platform_status', user_id)
return deleted
except Exception as e:
logger.error(f"Bing OAuth: Error purging expired tokens for user {user_id}: {e}")
return 0
def get_user_tokens(self, user_id: str) -> List[Dict[str, Any]]:
"""Get all active Bing tokens for a user."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, access_token, refresh_token, token_type, expires_at, scope, created_at
FROM bing_oauth_tokens
WHERE user_id = ? AND is_active = TRUE AND expires_at > datetime('now')
ORDER BY created_at DESC
''', (user_id,))
tokens = []
for row in cursor.fetchall():
tokens.append({
"id": row[0],
"access_token": row[1],
"refresh_token": row[2],
"token_type": row[3],
"expires_at": row[4],
"scope": row[5],
"created_at": row[6]
})
return tokens
except Exception as e:
logger.error(f"Error getting Bing tokens for user {user_id}: {e}")
return []
def get_user_token_status(self, user_id: str) -> Dict[str, Any]:
"""Get detailed token status for a user including expired tokens."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Get all tokens (active and expired)
cursor.execute('''
SELECT id, access_token, refresh_token, token_type, expires_at, scope, created_at, is_active
FROM bing_oauth_tokens
WHERE user_id = ?
ORDER BY created_at DESC
''', (user_id,))
all_tokens = []
active_tokens = []
expired_tokens = []
for row in cursor.fetchall():
token_data = {
"id": row[0],
"access_token": row[1],
"refresh_token": row[2],
"token_type": row[3],
"expires_at": row[4],
"scope": row[5],
"created_at": row[6],
"is_active": bool(row[7])
}
all_tokens.append(token_data)
# Determine expiry using robust parsing and is_active flag
is_active_flag = bool(row[7])
not_expired = False
try:
expires_at_val = row[4]
if expires_at_val:
# First try Python parsing
try:
dt = datetime.fromisoformat(expires_at_val) if isinstance(expires_at_val, str) else expires_at_val
not_expired = dt > datetime.now()
except Exception:
# Fallback to SQLite comparison
cursor.execute("SELECT datetime('now') < ?", (expires_at_val,))
not_expired = cursor.fetchone()[0] == 1
else:
# No expiry stored => consider not expired
not_expired = True
except Exception:
not_expired = False
if is_active_flag and not_expired:
active_tokens.append(token_data)
else:
expired_tokens.append(token_data)
return {
"has_tokens": len(all_tokens) > 0,
"has_active_tokens": len(active_tokens) > 0,
"has_expired_tokens": len(expired_tokens) > 0,
"active_tokens": active_tokens,
"expired_tokens": expired_tokens,
"total_tokens": len(all_tokens),
"last_token_date": all_tokens[0]["created_at"] if all_tokens else None
}
except Exception as e:
logger.error(f"Error getting Bing token status for user {user_id}: {e}")
return {
"has_tokens": False,
"has_active_tokens": False,
"has_expired_tokens": False,
"active_tokens": [],
"expired_tokens": [],
"total_tokens": 0,
"last_token_date": None,
"error": str(e)
}
def test_token(self, access_token: str) -> bool:
"""Test if a Bing access token is valid."""
try:
headers = {'Authorization': f'Bearer {access_token}'}
# Try to get user's sites to test token validity
response = requests.get(
f"{self.api_base_url}/GetUserSites",
headers={
**headers,
'Origin': 'https://www.bing.com',
'Referer': 'https://www.bing.com/webmasters/'
},
timeout=10
)
logger.info(f"Bing test_token: Status {response.status_code}")
if response.status_code != 200:
logger.warning(f"Bing test_token: API error {response.status_code} - {response.text}")
else:
logger.info(f"Bing test_token: Token is valid")
return response.status_code == 200
except Exception as e:
logger.error(f"Error testing Bing token: {e}")
return False
def refresh_access_token(self, user_id: str, refresh_token: str) -> Optional[Dict[str, Any]]:
"""Refresh an expired access token using refresh token."""
try:
logger.info(f"Bing refresh_access_token: Attempting to refresh token for user {user_id}")
logger.debug(f"Bing refresh_access_token: Using client_id={self.client_id[:10]}..., refresh_token={refresh_token[:20]}...")
token_data = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'refresh_token': refresh_token,
'grant_type': 'refresh_token'
}
response = requests.post(
f"{self.base_url}/webmasters/oauth/token",
data=token_data,
headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Origin': 'https://www.bing.com',
'Referer': 'https://www.bing.com/webmasters/'
},
timeout=30
)
logger.info(f"Bing refresh_access_token: Response status {response.status_code}")
if response.status_code != 200:
logger.error(f"Token refresh failed: {response.status_code} - {response.text}")
return None
token_info = response.json()
logger.info(f"Bing refresh_access_token: Successfully refreshed token")
# Update token in database
access_token = token_info.get('access_token')
expires_in = token_info.get('expires_in', 3600)
expires_at = datetime.now() + timedelta(seconds=expires_in)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE bing_oauth_tokens
SET access_token = ?, expires_at = ?, is_active = TRUE, updated_at = datetime('now')
WHERE user_id = ? AND refresh_token = ?
''', (access_token, expires_at, user_id, refresh_token))
conn.commit()
logger.info(f"Bing access token refreshed for user {user_id}")
# Invalidate caches that depend on token validity
try:
analytics_cache.invalidate('platform_status', user_id)
analytics_cache.invalidate('bing_sites', user_id)
except Exception as _:
pass
return {
"access_token": access_token,
"expires_in": expires_in,
"expires_at": expires_at.isoformat()
}
except Exception as e:
logger.error(f"Bing refresh_access_token: Error refreshing token: {e}")
return None
def revoke_token(self, user_id: str, token_id: int) -> bool:
"""Revoke a Bing OAuth token."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE bing_oauth_tokens
SET is_active = FALSE, updated_at = datetime('now')
WHERE user_id = ? AND id = ?
''', (user_id, token_id))
conn.commit()
if cursor.rowcount > 0:
logger.info(f"Bing token {token_id} revoked for user {user_id}")
return True
return False
except Exception as e:
logger.error(f"Error revoking Bing token: {e}")
return False
def get_connection_status(self, user_id: str) -> Dict[str, Any]:
"""Get Bing connection status for a user."""
try:
tokens = self.get_user_tokens(user_id)
if not tokens:
return {
"connected": False,
"sites": [],
"total_sites": 0
}
# Check cache first for sites data
cached_sites = analytics_cache.get('bing_sites', user_id)
if cached_sites:
logger.info(f"Using cached Bing sites for user {user_id}")
return {
"connected": True,
"sites": cached_sites,
"total_sites": len(cached_sites)
}
# If no cache, return basic connection status without making API calls
# Sites will be fetched when needed for analytics
logger.info(f"Bing tokens found for user {user_id}, returning basic connection status")
active_sites = []
for token in tokens:
# Just check if token exists and is not expired (basic check)
# Don't make external API calls for connection status
active_sites.append({
"id": token["id"],
"access_token": token["access_token"],
"scope": token["scope"],
"created_at": token["created_at"],
"sites": [] # Sites will be fetched when needed for analytics
})
return {
"connected": len(active_sites) > 0,
"sites": active_sites,
"total_sites": len(active_sites)
}
except Exception as e:
logger.error(f"Error getting Bing connection status: {e}")
return {
"connected": False,
"sites": [],
"total_sites": 0
}
def get_user_sites(self, user_id: str) -> List[Dict[str, Any]]:
"""Get list of user's verified sites from Bing Webmaster."""
try:
# Fast path: return cached sites if available
try:
cached_sites = analytics_cache.get('bing_sites', user_id)
if cached_sites:
logger.info(f"Bing get_user_sites: Returning {len(cached_sites)} cached sites for user {user_id}")
return cached_sites
except Exception:
pass
tokens = self.get_user_tokens(user_id)
logger.info(f"Bing get_user_sites: Found {len(tokens)} tokens for user {user_id}")
if not tokens:
logger.warning(f"Bing get_user_sites: No tokens found for user {user_id}")
return []
all_sites = []
for i, token in enumerate(tokens):
logger.info(f"Bing get_user_sites: Testing token {i+1}/{len(tokens)}")
# Try to refresh token if it's invalid
if not self.test_token(token["access_token"]):
logger.info(f"Bing get_user_sites: Token {i+1} is invalid, attempting refresh")
if token.get("refresh_token"):
refreshed_token = self.refresh_access_token(user_id, token["refresh_token"])
if refreshed_token:
logger.info(f"Bing get_user_sites: Token {i+1} refreshed successfully")
# Update the token in the database
self.update_token_in_db(token["id"], refreshed_token)
# Use the new token
token["access_token"] = refreshed_token["access_token"]
else:
logger.warning(f"Bing get_user_sites: Failed to refresh token {i+1} - refresh token may be expired")
# Mark token as inactive since refresh failed
self.mark_token_inactive(token["id"])
continue
else:
logger.warning(f"Bing get_user_sites: No refresh token available for token {i+1}")
continue
if self.test_token(token["access_token"]):
try:
headers = {'Authorization': f'Bearer {token["access_token"]}'}
response = requests.get(
f"{self.api_base_url}/GetUserSites",
headers={
**headers,
'Origin': 'https://www.bing.com',
'Referer': 'https://www.bing.com/webmasters/'
},
timeout=10
)
if response.status_code == 200:
sites_data = response.json()
logger.info(f"Bing API response: {response.status_code}, data type: {type(sites_data)}")
logger.debug(f"Bing API response structure: {type(sites_data)}, keys: {list(sites_data.keys()) if isinstance(sites_data, dict) else 'Not a dict'}")
logger.debug(f"Bing API response content: {sites_data}")
else:
logger.error(f"Bing API error: {response.status_code} - {response.text}")
continue
# Handle different response structures
if isinstance(sites_data, dict):
if 'd' in sites_data:
d_data = sites_data['d']
if isinstance(d_data, dict) and 'results' in d_data:
sites = d_data['results']
elif isinstance(d_data, list):
sites = d_data
else:
sites = []
else:
sites = []
elif isinstance(sites_data, list):
sites = sites_data
else:
sites = []
logger.info(f"Bing get_user_sites: Found {len(sites)} sites from token")
all_sites.extend(sites)
# Cache sites immediately for future calls
try:
analytics_cache.set('bing_sites', user_id, all_sites, ttl_override=2*60*60)
except Exception:
pass
except Exception as e:
logger.error(f"Error getting Bing user sites: {e}")
logger.info(f"Bing get_user_sites: Returning {len(all_sites)} total sites for user {user_id}")
# If no sites found and we had tokens, it means all tokens failed
if len(all_sites) == 0 and len(tokens) > 0:
logger.warning(f"Bing get_user_sites: No sites found despite having {len(tokens)} tokens - all tokens may be expired")
return all_sites
except Exception as e:
logger.error(f"Error getting Bing user sites: {e}")
return []
def update_token_in_db(self, token_id: str, refreshed_token: Dict[str, Any]) -> bool:
"""Update the access token in the database after refresh."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Compute expires_at from expires_in if expires_at missing
expires_at_value = refreshed_token.get("expires_at")
if not expires_at_value and refreshed_token.get("expires_in"):
try:
expires_at_value = datetime.now() + timedelta(seconds=int(refreshed_token["expires_in"]))
except Exception:
expires_at_value = None
cursor.execute('''
UPDATE bing_oauth_tokens
SET access_token = ?, expires_at = ?, is_active = TRUE, updated_at = datetime('now')
WHERE id = ?
''', (
refreshed_token["access_token"],
expires_at_value,
token_id
))
conn.commit()
logger.info(f"Bing token {token_id} updated in database")
return True
except Exception as e:
logger.error(f"Error updating Bing token in database: {e}")
return False
def mark_token_inactive(self, token_id: str) -> bool:
"""Mark a token as inactive in the database."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE bing_oauth_tokens
SET is_active = FALSE, updated_at = datetime('now')
WHERE id = ?
''', (token_id,))
conn.commit()
logger.info(f"Bing token {token_id} marked as inactive")
return True
except Exception as e:
logger.error(f"Error marking Bing token as inactive: {e}")
return False
def get_rank_and_traffic_stats(self, user_id: str, site_url: str, start_date: str = None, end_date: str = None) -> Dict[str, Any]:
"""Get rank and traffic statistics for a site."""
try:
tokens = self.get_user_tokens(user_id)
if not tokens:
return {"error": "No valid tokens found"}
# Use the first valid token
valid_token = None
for token in tokens:
if self.test_token(token["access_token"]):
valid_token = token
break
if not valid_token:
return {"error": "No valid access token"}
# Set default date range (last 30 days)
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
headers = {'Authorization': f'Bearer {valid_token["access_token"]}'}
params = {
'siteUrl': site_url,
'startDate': start_date,
'endDate': end_date
}
response = requests.get(
f"{self.api_base_url}/GetRankAndTrafficStats",
headers=headers,
params=params,
timeout=15
)
if response.status_code == 200:
return response.json()
else:
logger.error(f"Bing API error: {response.status_code} - {response.text}")
return {"error": f"API error: {response.status_code}"}
except Exception as e:
logger.error(f"Error getting Bing rank and traffic stats: {e}")
return {"error": str(e)}
def get_query_stats(self, user_id: str, site_url: str, start_date: str = None, end_date: str = None, page: int = 0) -> Dict[str, Any]:
"""Get search query statistics for a site."""
try:
tokens = self.get_user_tokens(user_id)
if not tokens:
return {"error": "No valid tokens found"}
valid_token = None
for token in tokens:
if self.test_token(token["access_token"]):
valid_token = token
break
if not valid_token:
return {"error": "No valid access token"}
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
headers = {'Authorization': f'Bearer {valid_token["access_token"]}'}
params = {
'siteUrl': site_url,
'startDate': start_date,
'endDate': end_date,
'page': page
}
response = requests.get(
f"{self.api_base_url}/GetQueryStats",
headers=headers,
params=params,
timeout=15
)
if response.status_code == 200:
return response.json()
else:
logger.error(f"Bing API error: {response.status_code} - {response.text}")
return {"error": f"API error: {response.status_code}"}
except Exception as e:
logger.error(f"Error getting Bing query stats: {e}")
return {"error": str(e)}
def get_page_stats(self, user_id: str, site_url: str, start_date: str = None, end_date: str = None, page: int = 0) -> Dict[str, Any]:
"""Get page-level statistics for a site."""
try:
tokens = self.get_user_tokens(user_id)
if not tokens:
return {"error": "No valid tokens found"}
valid_token = None
for token in tokens:
if self.test_token(token["access_token"]):
valid_token = token
break
if not valid_token:
return {"error": "No valid access token"}
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
headers = {'Authorization': f'Bearer {valid_token["access_token"]}'}
params = {
'siteUrl': site_url,
'startDate': start_date,
'endDate': end_date,
'page': page
}
response = requests.get(
f"{self.api_base_url}/GetPageStats",
headers=headers,
params=params,
timeout=15
)
if response.status_code == 200:
return response.json()
else:
logger.error(f"Bing API error: {response.status_code} - {response.text}")
return {"error": f"API error: {response.status_code}"}
except Exception as e:
logger.error(f"Error getting Bing page stats: {e}")
return {"error": str(e)}
def get_keyword_stats(self, user_id: str, keyword: str, country: str = "us", language: str = "en-US") -> Dict[str, Any]:
"""Get keyword statistics for research purposes."""
try:
tokens = self.get_user_tokens(user_id)
if not tokens:
return {"error": "No valid tokens found"}
valid_token = None
for token in tokens:
if self.test_token(token["access_token"]):
valid_token = token
break
if not valid_token:
return {"error": "No valid access token"}
headers = {'Authorization': f'Bearer {valid_token["access_token"]}'}
params = {
'q': keyword,
'country': country,
'language': language
}
response = requests.get(
f"{self.api_base_url}/GetKeywordStats",
headers=headers,
params=params,
timeout=15
)
if response.status_code == 200:
return response.json()
else:
logger.error(f"Bing API error: {response.status_code} - {response.text}")
return {"error": f"API error: {response.status_code}"}
except Exception as e:
logger.error(f"Error getting Bing keyword stats: {e}")
return {"error": str(e)}
def get_comprehensive_analytics(self, user_id: str, site_url: str = None) -> Dict[str, Any]:
"""Get comprehensive analytics data for all connected sites or a specific site."""
try:
# Get user's sites
sites = self.get_user_sites(user_id)
if not sites:
return {"error": "No sites found"}
# If no specific site URL provided, get data for all sites
target_sites = [site_url] if site_url else [site.get('url', '') for site in sites if site.get('url')]
analytics_data = {
"sites": [],
"summary": {
"total_sites": len(target_sites),
"total_clicks": 0,
"total_impressions": 0,
"total_ctr": 0.0
}
}
for site in target_sites:
if not site:
continue
site_data = {
"url": site,
"traffic_stats": {},
"query_stats": {},
"page_stats": {},
"error": None
}
try:
# Get traffic stats
traffic_stats = self.get_rank_and_traffic_stats(user_id, site)
if "error" not in traffic_stats:
site_data["traffic_stats"] = traffic_stats
# Get query stats (first page)
query_stats = self.get_query_stats(user_id, site)
if "error" not in query_stats:
site_data["query_stats"] = query_stats
# Get page stats (first page)
page_stats = self.get_page_stats(user_id, site)
if "error" not in page_stats:
site_data["page_stats"] = page_stats
except Exception as e:
site_data["error"] = str(e)
logger.error(f"Error getting analytics for site {site}: {e}")
analytics_data["sites"].append(site_data)
return analytics_data
except Exception as e:
logger.error(f"Error getting comprehensive Bing analytics: {e}")
return {"error": str(e)}

View File

@@ -0,0 +1,15 @@
"""
Wix integration modular services package.
"""
from services.integrations.wix.seo import build_seo_data
from services.integrations.wix.ricos_converter import markdown_to_html, convert_via_wix_api
from services.integrations.wix.blog_publisher import create_blog_post
__all__ = [
'build_seo_data',
'markdown_to_html',
'convert_via_wix_api',
'create_blog_post',
]

View File

@@ -0,0 +1,86 @@
from typing import Any, Dict, Optional, Tuple
import requests
from loguru import logger
import base64
import hashlib
import secrets
class WixAuthService:
def __init__(self, client_id: Optional[str], redirect_uri: str, base_url: str):
self.client_id = client_id
self.redirect_uri = redirect_uri
self.base_url = base_url
def generate_authorization_url(self, state: Optional[str] = None) -> Tuple[str, str]:
if not self.client_id:
raise ValueError("Wix client ID not configured")
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode('utf-8')).digest()
).decode('utf-8').rstrip('=')
oauth_url = 'https://www.wix.com/oauth/authorize'
from urllib.parse import urlencode
params = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': (
'BLOG.CREATE-DRAFT,BLOG.PUBLISH-POST,BLOG.READ-CATEGORY,'
'BLOG.CREATE-CATEGORY,BLOG.READ-TAG,BLOG.CREATE-TAG,'
'MEDIA.SITE_MEDIA_FILES_IMPORT'
),
'code_challenge': code_challenge,
'code_challenge_method': 'S256'
}
if state:
params['state'] = state
return f"{oauth_url}?{urlencode(params)}", code_verifier
def exchange_code_for_tokens(self, code: str, code_verifier: str) -> Dict[str, Any]:
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
data = {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.redirect_uri,
'client_id': self.client_id,
'code_verifier': code_verifier,
}
token_url = f'{self.base_url}/oauth2/token'
response = requests.post(token_url, headers=headers, data=data)
response.raise_for_status()
return response.json()
def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
data = {
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
'client_id': self.client_id,
}
token_url = f'{self.base_url}/oauth2/token'
response = requests.post(token_url, headers=headers, data=data)
response.raise_for_status()
return response.json()
def get_site_info(self, access_token: str) -> Dict[str, Any]:
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
response = requests.get(f"{self.base_url}/sites/v1/site", headers=headers)
response.raise_for_status()
return response.json()
def get_current_member(self, access_token: str, client_id: Optional[str]) -> Dict[str, Any]:
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
if client_id:
headers['wix-client-id'] = client_id
response = requests.get(f"{self.base_url}/members/v1/members/my", headers=headers)
response.raise_for_status()
return response.json()

View File

@@ -0,0 +1,132 @@
"""
Authentication utilities for Wix API requests.
Supports both OAuth Bearer tokens and API keys for Wix Headless apps.
"""
import os
from typing import Dict, Optional
from loguru import logger
def get_wix_headers(
access_token: str,
client_id: Optional[str] = None,
extra: Optional[Dict[str, str]] = None
) -> Dict[str, str]:
"""
Build headers for Wix API requests with automatic token type detection.
Supports:
- OAuth Bearer tokens (JWT format: xxx.yyy.zzz)
- Wix API keys (for Headless apps)
Args:
access_token: OAuth token OR API key
client_id: Optional Wix client ID
extra: Additional headers to include
Returns:
Headers dict with proper Authorization format
"""
headers: Dict[str, str] = {
'Content-Type': 'application/json',
}
if access_token:
# Ensure access_token is a string (defensive check)
if not isinstance(access_token, str):
from services.integrations.wix.utils import normalize_token_string
normalized = normalize_token_string(access_token)
if normalized:
access_token = normalized
else:
access_token = str(access_token)
token = access_token.strip()
if token:
# Detect token type
# API keys are typically longer and don't have JWT structure (xxx.yyy.zzz)
# JWT tokens have exactly 2 dots separating 3 parts
# Wix OAuth tokens can have format "OauthNG.JWS.xxx.yyy.zzz"
# CRITICAL: Wix OAuth tokens can have format "OauthNG.JWS.xxx.yyy.zzz"
# These should use "Bearer" prefix even though they have more than 2 dots
if token.startswith('OauthNG.JWS.'):
# Wix OAuth token - use Bearer prefix
headers['Authorization'] = f'Bearer {token}'
logger.debug(f"Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
else:
# Count dots - JWT has exactly 2 dots
dot_count = token.count('.')
if dot_count == 2 and len(token) < 500:
# Likely OAuth JWT token - use Bearer prefix
headers['Authorization'] = f'Bearer {token}'
logger.debug(f"Using OAuth Bearer token (JWT format detected)")
else:
# Likely API key - use directly without Bearer prefix
headers['Authorization'] = token
logger.debug(f"Using API key for authorization (non-JWT format detected)")
if client_id:
headers['wix-client-id'] = client_id
if extra:
headers.update(extra)
return headers
def get_wix_api_key() -> Optional[str]:
"""
Get Wix API key from environment.
For Wix Headless apps, API keys provide admin-level access.
Returns:
API key if set, None otherwise
"""
api_key = os.getenv('WIX_API_KEY')
if api_key:
logger.warning(f"✅ Wix API key found in environment ({len(api_key)} chars)")
else:
logger.warning("❌ No Wix API key in environment")
return api_key
def should_use_api_key(access_token: Optional[str] = None) -> bool:
"""
Determine if we should use API key instead of OAuth token.
Use API key if:
- No OAuth token provided
- OAuth token is getting 403 errors
- API key is available in environment
Args:
access_token: Optional OAuth token
Returns:
True if should use API key, False otherwise
"""
# If no access token, check for API key
if not access_token or not access_token.strip():
return get_wix_api_key() is not None
# If access token looks like API key already, use it
# Ensure access_token is a string (defensive check)
if not isinstance(access_token, str):
from services.integrations.wix.utils import normalize_token_string
normalized = normalize_token_string(access_token)
if normalized:
access_token = normalized
else:
access_token = str(access_token)
token = access_token.strip()
if token.count('.') != 2 or len(token) > 500:
return True
return False

View File

@@ -0,0 +1,121 @@
from typing import Any, Dict, List, Optional
import requests
from loguru import logger
class WixBlogService:
def __init__(self, base_url: str, client_id: Optional[str]):
self.base_url = base_url
self.client_id = client_id
def headers(self, access_token: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
h: Dict[str, str] = {
'Content-Type': 'application/json',
}
# Support both OAuth tokens and API keys
# API keys don't use 'Bearer' prefix
# Ensure access_token is a string (defensive check)
if access_token:
# Normalize token to string if needed
if not isinstance(access_token, str):
from .utils import normalize_token_string
normalized = normalize_token_string(access_token)
if normalized:
access_token = normalized
else:
access_token = str(access_token)
token = access_token.strip()
if token:
# CRITICAL: Wix OAuth tokens can have format "OauthNG.JWS.xxx.yyy.zzz"
# These should use "Bearer" prefix even though they have more than 2 dots
if token.startswith('OauthNG.JWS.'):
# Wix OAuth token - use Bearer prefix
h['Authorization'] = f'Bearer {token}'
logger.debug("Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
elif '.' not in token or len(token) > 500:
# Likely an API key - use directly without Bearer prefix
h['Authorization'] = token
logger.debug("Using API key for authorization")
else:
# Standard JWT OAuth token (xxx.yyy.zzz format) - use Bearer prefix
h['Authorization'] = f'Bearer {token}'
logger.debug("Using OAuth Bearer token for authorization")
if self.client_id:
h['wix-client-id'] = self.client_id
if extra:
h.update(extra)
return h
def create_draft_post(self, access_token: str, payload: Dict[str, Any], extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Create draft post with consolidated logging"""
from .logger import wix_logger
import json
# Build payload summary for logging
payload_summary = {}
if 'draftPost' in payload:
dp = payload['draftPost']
payload_summary['draftPost'] = {
'title': dp.get('title'),
'richContent': {'nodes': len(dp.get('richContent', {}).get('nodes', []))} if 'richContent' in dp else None,
'seoData': 'seoData' in dp
}
request_headers = self.headers(access_token, extra_headers)
response = requests.post(f"{self.base_url}/blog/v3/draft-posts", headers=request_headers, json=payload)
# Consolidated error logging
error_body = None
if response.status_code >= 400:
try:
error_body = response.json()
except:
error_body = {'message': response.text[:200]}
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", response.status_code, payload_summary, error_body)
if response.status_code >= 400:
# Only show detailed error info for debugging
if response.status_code == 500:
logger.debug(f" Full error: {json.dumps(error_body, indent=2) if isinstance(error_body, dict) else error_body}")
response.raise_for_status()
return response.json()
def publish_draft(self, access_token: str, draft_post_id: str, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
response = requests.post(f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}/publish", headers=self.headers(access_token, extra_headers))
response.raise_for_status()
return response.json()
def list_categories(self, access_token: str, extra_headers: Optional[Dict[str, str]] = None) -> List[Dict[str, Any]]:
response = requests.get(f"{self.base_url}/blog/v3/categories", headers=self.headers(access_token, extra_headers))
response.raise_for_status()
return response.json().get('categories', [])
def create_category(self, access_token: str, label: str, description: Optional[str] = None, language: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
payload: Dict[str, Any] = {'category': {'label': label}, 'fieldsets': ['URL']}
if description:
payload['category']['description'] = description
if language:
payload['category']['language'] = language
response = requests.post(f"{self.base_url}/blog/v3/categories", headers=self.headers(access_token, extra_headers), json=payload)
response.raise_for_status()
return response.json()
def list_tags(self, access_token: str, extra_headers: Optional[Dict[str, str]] = None) -> List[Dict[str, Any]]:
response = requests.get(f"{self.base_url}/blog/v3/tags", headers=self.headers(access_token, extra_headers))
response.raise_for_status()
return response.json().get('tags', [])
def create_tag(self, access_token: str, label: str, language: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
payload: Dict[str, Any] = {'label': label, 'fieldsets': ['URL']}
if language:
payload['language'] = language
response = requests.post(f"{self.base_url}/blog/v3/tags", headers=self.headers(access_token, extra_headers), json=payload)
response.raise_for_status()
return response.json()

View File

@@ -0,0 +1,743 @@
"""
Blog Post Publisher for Wix
Handles blog post creation, validation, and publishing to Wix.
"""
import json
import uuid
import requests
import jwt
from typing import Dict, Any, Optional, List
from loguru import logger
from services.integrations.wix.blog import WixBlogService
from services.integrations.wix.content import convert_content_to_ricos
from services.integrations.wix.ricos_converter import convert_via_wix_api
from services.integrations.wix.seo import build_seo_data
from services.integrations.wix.logger import wix_logger
from services.integrations.wix.utils import normalize_token_string
def validate_ricos_content(ricos_content: Dict[str, Any]) -> Dict[str, Any]:
"""
Validate and normalize Ricos document structure.
Args:
ricos_content: Ricos document dict
Returns:
Validated and normalized Ricos document
"""
# Validate Ricos document structure before using
if not ricos_content or not isinstance(ricos_content, dict):
logger.error("Invalid Ricos content - not a dict")
raise ValueError("Failed to convert content to valid Ricos format")
if 'type' not in ricos_content:
ricos_content['type'] = 'DOCUMENT'
logger.debug("Added missing richContent type 'DOCUMENT'")
if ricos_content.get('type') != 'DOCUMENT':
logger.warning(f"richContent type expected 'DOCUMENT', got {ricos_content.get('type')}, correcting")
ricos_content['type'] = 'DOCUMENT'
if 'id' not in ricos_content or not isinstance(ricos_content.get('id'), str):
ricos_content['id'] = str(uuid.uuid4())
logger.debug("Added missing richContent id")
if 'nodes' not in ricos_content:
logger.warning("Ricos document missing 'nodes' field, adding empty nodes array")
ricos_content['nodes'] = []
logger.debug(f"Ricos document structure: nodes={len(ricos_content.get('nodes', []))}")
# Validate richContent is a proper object with nodes array
# Per Wix API: richContent must be a RichContent object with nodes array
if not isinstance(ricos_content, dict):
raise ValueError(f"richContent must be a dict object, got {type(ricos_content)}")
# Ensure nodes array exists and is valid
if 'nodes' not in ricos_content:
logger.warning("richContent missing 'nodes', adding empty array")
ricos_content['nodes'] = []
if not isinstance(ricos_content['nodes'], list):
raise ValueError(f"richContent.nodes must be a list, got {type(ricos_content['nodes'])}")
# Recursive function to validate and fix nodes at any depth
def validate_node_recursive(node: Dict[str, Any], path: str = "root") -> None:
"""
Recursively validate a node and all its nested children, ensuring:
1. All required data fields exist for each node type
2. All 'nodes' arrays are proper lists
3. No None values in critical fields
"""
if not isinstance(node, dict):
logger.error(f"{path}: Node is not a dict: {type(node)}")
return
# Ensure type and id exist
if 'type' not in node:
logger.error(f"{path}: Missing 'type' field - REQUIRED")
node['type'] = 'PARAGRAPH' # Default fallback
if 'id' not in node:
node['id'] = str(uuid.uuid4())
logger.debug(f"{path}: Added missing 'id'")
node_type = node.get('type')
# CRITICAL: Per Wix API schema, data fields like paragraphData, bulletedListData, etc.
# are OPTIONAL and should be OMITTED entirely when empty, not included as {}
# Only validate fields that have required properties
# Special handling: Remove listItemData if it exists (not in Wix API schema)
if node_type == 'LIST_ITEM' and 'listItemData' in node:
logger.debug(f"{path}: Removing incorrect listItemData field from LIST_ITEM")
del node['listItemData']
# Only validate HEADING nodes - they require headingData with level property
if node_type == 'HEADING':
if 'headingData' not in node or not isinstance(node.get('headingData'), dict):
logger.warning(f"{path} (HEADING): Missing headingData, adding default level 1")
node['headingData'] = {'level': 1}
elif 'level' not in node['headingData']:
logger.warning(f"{path} (HEADING): Missing level in headingData, adding default")
node['headingData']['level'] = 1
# TEXT nodes must have textData
if node_type == 'TEXT':
if 'textData' not in node or not isinstance(node.get('textData'), dict):
logger.error(f"{path} (TEXT): Missing/invalid textData - node will be problematic")
node['textData'] = {'text': '', 'decorations': []}
# LINK and IMAGE nodes must have their data fields
if node_type == 'LINK' and ('linkData' not in node or not isinstance(node.get('linkData'), dict)):
logger.error(f"{path} (LINK): Missing/invalid linkData - node will be problematic")
if node_type == 'IMAGE' and ('imageData' not in node or not isinstance(node.get('imageData'), dict)):
logger.error(f"{path} (IMAGE): Missing/invalid imageData - node will be problematic")
# Remove None values from any data fields that exist (Wix API rejects None)
for data_field in ['headingData', 'paragraphData', 'blockquoteData', 'bulletedListData',
'orderedListData', 'textData', 'linkData', 'imageData']:
if data_field in node and isinstance(node[data_field], dict):
data_value = node[data_field]
keys_to_remove = [k for k, v in data_value.items() if v is None]
if keys_to_remove:
logger.debug(f"{path} ({node_type}): Removing None values from {data_field}: {keys_to_remove}")
for key in keys_to_remove:
del data_value[key]
# Ensure 'nodes' field exists for container nodes
container_types = ['HEADING', 'PARAGRAPH', 'BLOCKQUOTE', 'LIST_ITEM', 'LINK',
'BULLETED_LIST', 'ORDERED_LIST']
if node_type in container_types:
if 'nodes' not in node:
logger.warning(f"{path} ({node_type}): Missing 'nodes' field, adding empty array")
node['nodes'] = []
elif not isinstance(node['nodes'], list):
logger.error(f"{path} ({node_type}): Invalid 'nodes' field (not a list), fixing")
node['nodes'] = []
# Recursively validate all nested nodes
for nested_idx, nested_node in enumerate(node['nodes']):
nested_path = f"{path}.nodes[{nested_idx}]"
validate_node_recursive(nested_node, nested_path)
# Validate all top-level nodes recursively
for idx, node in enumerate(ricos_content['nodes']):
validate_node_recursive(node, f"nodes[{idx}]")
# Ensure documentStyle exists and is a dict (required by Wix API when provided)
if 'metadata' not in ricos_content or not isinstance(ricos_content.get('metadata'), dict):
ricos_content['metadata'] = {'version': 1, 'id': str(uuid.uuid4())}
logger.debug("Added default metadata to richContent")
else:
ricos_content['metadata'].setdefault('version', 1)
ricos_content['metadata'].setdefault('id', str(uuid.uuid4()))
if 'documentStyle' not in ricos_content or not isinstance(ricos_content.get('documentStyle'), dict):
ricos_content['documentStyle'] = {
'paragraph': {
'decorations': [],
'nodeStyle': {},
'lineHeight': '1.5'
}
}
logger.debug("Added default documentStyle to richContent")
logger.debug(f"✅ Validated richContent: {len(ricos_content['nodes'])} nodes, has_metadata={bool(ricos_content.get('metadata'))}, has_documentStyle={bool(ricos_content.get('documentStyle'))}")
return ricos_content
def validate_payload_no_none(obj, path=""):
"""Recursively validate that no None values exist in the payload"""
if obj is None:
raise ValueError(f"Found None value at path: {path}")
if isinstance(obj, dict):
for key, value in obj.items():
validate_payload_no_none(value, f"{path}.{key}" if path else key)
elif isinstance(obj, list):
for idx, item in enumerate(obj):
validate_payload_no_none(item, f"{path}[{idx}]" if path else f"[{idx}]")
def create_blog_post(
blog_service: WixBlogService,
access_token: str,
title: str,
content: str,
member_id: str,
cover_image_url: str = None,
category_ids: List[str] = None,
tag_ids: List[str] = None,
publish: bool = True,
seo_metadata: Dict[str, Any] = None,
import_image_func = None,
lookup_categories_func = None,
lookup_tags_func = None,
base_url: str = 'https://www.wixapis.com'
) -> Dict[str, Any]:
"""
Create and optionally publish a blog post on Wix
Args:
blog_service: WixBlogService instance
access_token: Valid access token
title: Blog post title
content: Blog post content (markdown)
member_id: Required for third-party apps - the member ID of the post author
cover_image_url: Optional cover image URL
category_ids: Optional list of category IDs or names
tag_ids: Optional list of tag IDs or names
publish: Whether to publish immediately or save as draft
seo_metadata: Optional SEO metadata dict
import_image_func: Function to import images (optional)
lookup_categories_func: Function to lookup/create categories (optional)
lookup_tags_func: Function to lookup/create tags (optional)
base_url: Wix API base URL
Returns:
Created blog post information
"""
if not member_id:
raise ValueError("memberId is required for third-party apps creating blog posts")
# Ensure access_token is a string (handle cases where it might be int, dict, or other type)
# Use normalize_token_string to handle various token formats (dict with accessToken.value, etc.)
normalized_token = normalize_token_string(access_token)
if not normalized_token:
raise ValueError("access_token is required and must be a valid string or token object")
access_token = normalized_token.strip()
if not access_token:
raise ValueError("access_token cannot be empty")
# BACK TO BASICS MODE: Try simplest possible structure FIRST
# Since posting worked before Ricos/SEO, let's test with absolute minimum
BACK_TO_BASICS_MODE = True # Set to True to test with simplest structure
wix_logger.reset()
wix_logger.log_operation_start("Blog Post Creation", title=title[:50] if title else None, member_id=member_id[:20] if member_id else None)
if BACK_TO_BASICS_MODE:
logger.info("🔧 Wix: BACK TO BASICS MODE - Testing minimal structure")
# Import auth utilities for proper token handling
from .auth_utils import get_wix_headers
# Create absolute minimal Ricos structure
minimal_ricos = {
'nodes': [{
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [],
'textData': {
'text': (content[:500] if content else "This is a post from ALwrity.").strip(),
'decorations': []
}
}],
'paragraphData': {}
}]
}
# Extract wix-site-id from token if possible
extra_headers = {}
try:
token_str = str(access_token)
if token_str and token_str.startswith('OauthNG.JWS.'):
import jwt
import json
jwt_part = token_str[12:]
payload = jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
data_payload = payload.get('data', {})
if isinstance(data_payload, str):
try:
data_payload = json.loads(data_payload)
except:
pass
instance_data = data_payload.get('instance', {})
meta_site_id = instance_data.get('metaSiteId')
if isinstance(meta_site_id, str) and meta_site_id:
extra_headers['wix-site-id'] = meta_site_id
except Exception:
pass
# Build minimal payload
minimal_blog_data = {
'draftPost': {
'title': str(title).strip() if title else "Untitled",
'memberId': str(member_id).strip(),
'richContent': minimal_ricos
},
'publish': False,
'fieldsets': ['URL']
}
try:
from .blog import WixBlogService
blog_service_test = WixBlogService('https://www.wixapis.com', None)
result = blog_service_test.create_draft_post(access_token, minimal_blog_data, extra_headers if extra_headers else None)
logger.success("✅✅✅ Wix: BACK TO BASICS SUCCEEDED! Issue is with Ricos/SEO structure")
wix_logger.log_operation_result("Back to Basics Test", True, result)
return result
except Exception as e:
logger.error(f"❌ Wix: BACK TO BASICS FAILED - {str(e)[:100]}")
logger.error(" ⚠️ Issue is NOT with Ricos/SEO - likely permissions/token")
wix_logger.add_error(f"Back to Basics: {str(e)[:100]}")
# Import auth utilities for proper token handling
from .auth_utils import get_wix_headers
# Headers for blog post creation (use user's OAuth token)
headers = get_wix_headers(access_token)
# Build valid Ricos rich content
# Ensure content is not empty
if not content or not content.strip():
content = "This is a post from ALwrity."
logger.warning("⚠️ Content was empty, using default text")
# Quick token/permission check (only log if issues found)
has_blog_scope = None
meta_site_id = None
try:
from .utils import decode_wix_token
import json
token_data = decode_wix_token(access_token)
if 'scope' in token_data:
scopes = token_data.get('scope')
if isinstance(scopes, str):
scope_list = scopes.split(',') if ',' in scopes else [scopes]
has_blog_scope = any('BLOG' in s.upper() for s in scope_list)
if not has_blog_scope:
logger.error("❌ Wix: Token missing BLOG scopes - verify OAuth app permissions")
if 'data' in token_data:
data = token_data.get('data')
if isinstance(data, str):
try:
data = json.loads(data)
except:
pass
if isinstance(data, dict) and 'instance' in data:
instance = data.get('instance', {})
meta_site_id = instance.get('metaSiteId')
except Exception:
pass
# Quick permission test (only log failures)
try:
test_headers = get_wix_headers(access_token)
import requests
test_response = requests.get(f"{base_url}/blog/v3/categories", headers=test_headers, timeout=5)
if test_response.status_code == 403:
logger.error("❌ Wix: Permission denied - OAuth app missing BLOG.CREATE-DRAFT")
elif test_response.status_code == 401:
logger.error("❌ Wix: Unauthorized - token may be expired")
except Exception:
pass
# Safely get token length (access_token is already validated as string above)
token_length = len(access_token) if access_token else 0
wix_logger.log_token_info(token_length, has_blog_scope, meta_site_id)
# Convert markdown to Ricos
ricos_content = convert_content_to_ricos(content, None)
nodes_count = len(ricos_content.get('nodes', []))
wix_logger.log_ricos_conversion(nodes_count)
# Validate Ricos content structure
# Per Wix Blog API documentation: richContent should ONLY contain 'nodes'
# The example in docs shows: { nodes: [...] } - no type, id, metadata, or documentStyle
if not isinstance(ricos_content, dict):
logger.error(f"❌ richContent is not a dict: {type(ricos_content)}")
raise ValueError("richContent must be a dictionary object")
if 'nodes' not in ricos_content or not isinstance(ricos_content['nodes'], list):
logger.error(f"❌ richContent.nodes is missing or not a list: {ricos_content.get('nodes', 'MISSING')}")
raise ValueError("richContent must contain a 'nodes' array")
# Remove type and id fields (not expected by Blog API)
# NOTE: metadata is optional - Wix UPDATE endpoint example shows it, but CREATE example doesn't
# We'll keep it minimal (nodes only) for CREATE to match the recipe example
fields_to_remove = ['type', 'id']
for field in fields_to_remove:
if field in ricos_content:
logger.debug(f"Removing '{field}' field from richContent (Blog API doesn't expect this)")
del ricos_content[field]
# Remove metadata and documentStyle - Blog API CREATE endpoint example shows only 'nodes'
# (UPDATE endpoint shows metadata, but we're using CREATE)
if 'metadata' in ricos_content:
logger.debug("Removing 'metadata' from richContent (CREATE endpoint expects only 'nodes')")
del ricos_content['metadata']
if 'documentStyle' in ricos_content:
logger.debug("Removing 'documentStyle' from richContent (CREATE endpoint expects only 'nodes')")
del ricos_content['documentStyle']
# Ensure we only have 'nodes' in richContent for CREATE endpoint
ricos_content = {'nodes': ricos_content['nodes']}
logger.debug(f"✅ richContent structure validated: {len(ricos_content['nodes'])} nodes, keys: {list(ricos_content.keys())}")
# Minimal payload per Wix docs: title, memberId, and richContent
# CRITICAL: Only include fields that have valid values (no None, no empty strings for required fields)
blog_data = {
'draftPost': {
'title': str(title).strip() if title else "Untitled",
'memberId': str(member_id).strip(), # Required for third-party apps (validated above)
'richContent': ricos_content, # Must be a valid Ricos object with ONLY 'nodes'
},
'publish': bool(publish),
'fieldsets': ['URL'] # Simplified fieldsets
}
# Add excerpt only if content exists and is not empty (avoid None or empty strings)
excerpt = (content or '').strip()[:200] if content else None
if excerpt and len(excerpt) > 0:
blog_data['draftPost']['excerpt'] = str(excerpt)
# Add cover image if provided
if cover_image_url and import_image_func:
try:
media_id = import_image_func(access_token, cover_image_url, f'Cover: {title}')
# Ensure media_id is a string and not None
if media_id and isinstance(media_id, str):
blog_data['draftPost']['media'] = {
'wixMedia': {
'image': {'id': str(media_id).strip()}
},
'displayed': True,
'custom': True
}
else:
logger.warning(f"Invalid media_id type or value: {type(media_id)}, skipping media")
except Exception as e:
logger.warning(f"Failed to import cover image: {e}")
# Handle categories - can be either IDs (list of strings) or names (for lookup)
category_ids_to_use = None
if category_ids:
# Check if these are IDs (UUIDs) or names
if isinstance(category_ids, list) and len(category_ids) > 0:
# Assume IDs if first item looks like UUID (has hyphens and is long)
first_item = str(category_ids[0])
if '-' in first_item and len(first_item) > 30:
category_ids_to_use = category_ids
elif lookup_categories_func:
# These are names, need to lookup/create
extra_headers = {}
if 'wix-site-id' in headers:
extra_headers['wix-site-id'] = headers['wix-site-id']
category_ids_to_use = lookup_categories_func(
access_token, category_ids, extra_headers if extra_headers else None
)
# Handle tags - can be either IDs (list of strings) or names (for lookup)
tag_ids_to_use = None
if tag_ids:
# Check if these are IDs (UUIDs) or names
if isinstance(tag_ids, list) and len(tag_ids) > 0:
# Assume IDs if first item looks like UUID (has hyphens and is long)
first_item = str(tag_ids[0])
if '-' in first_item and len(first_item) > 30:
tag_ids_to_use = tag_ids
elif lookup_tags_func:
# These are names, need to lookup/create
extra_headers = {}
if 'wix-site-id' in headers:
extra_headers['wix-site-id'] = headers['wix-site-id']
tag_ids_to_use = lookup_tags_func(
access_token, tag_ids, extra_headers if extra_headers else None
)
# Add categories if we have IDs (must be non-empty list of strings)
# CRITICAL: Wix API rejects empty arrays or arrays with None/empty strings
if category_ids_to_use and isinstance(category_ids_to_use, list) and len(category_ids_to_use) > 0:
# Filter out None, empty strings, and ensure all are valid UUID strings
valid_category_ids = [str(cid).strip() for cid in category_ids_to_use if cid and str(cid).strip()]
if valid_category_ids:
blog_data['draftPost']['categoryIds'] = valid_category_ids
logger.debug(f"Added {len(valid_category_ids)} category IDs")
else:
logger.warning("All category IDs were invalid, not including categoryIds in payload")
# Add tags if we have IDs (must be non-empty list of strings)
# CRITICAL: Wix API rejects empty arrays or arrays with None/empty strings
if tag_ids_to_use and isinstance(tag_ids_to_use, list) and len(tag_ids_to_use) > 0:
# Filter out None, empty strings, and ensure all are valid UUID strings
valid_tag_ids = [str(tid).strip() for tid in tag_ids_to_use if tid and str(tid).strip()]
if valid_tag_ids:
blog_data['draftPost']['tagIds'] = valid_tag_ids
logger.debug(f"Added {len(valid_tag_ids)} tag IDs")
else:
logger.warning("All tag IDs were invalid, not including tagIds in payload")
# Build SEO data from metadata if provided
# NOTE: seoData is optional - if it causes issues, we can create post without it
seo_data = None
if seo_metadata:
try:
seo_data = build_seo_data(seo_metadata, title)
if seo_data:
tags_count = len(seo_data.get('tags', []))
keywords_count = len(seo_data.get('settings', {}).get('keywords', []))
wix_logger.log_seo_data(tags_count, keywords_count)
blog_data['draftPost']['seoData'] = seo_data
except Exception as e:
logger.warning(f"⚠️ Wix: SEO data build failed - {str(e)[:50]}")
wix_logger.add_warning(f"SEO build: {str(e)[:50]}")
# Add SEO slug if provided
if seo_metadata.get('url_slug'):
blog_data['draftPost']['seoSlug'] = str(seo_metadata.get('url_slug')).strip()
else:
logger.warning("⚠️ No SEO metadata provided to create_blog_post")
try:
# Extract wix-site-id from token if possible
extra_headers = {}
try:
token_str = str(access_token)
if token_str and token_str.startswith('OauthNG.JWS.'):
import jwt
import json
jwt_part = token_str[12:]
payload = jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
data_payload = payload.get('data', {})
if isinstance(data_payload, str):
try:
data_payload = json.loads(data_payload)
except:
pass
instance_data = data_payload.get('instance', {})
meta_site_id = instance_data.get('metaSiteId')
if isinstance(meta_site_id, str) and meta_site_id:
extra_headers['wix-site-id'] = meta_site_id
headers['wix-site-id'] = meta_site_id
except Exception:
pass
# Validate payload structure before sending
draft_post = blog_data.get('draftPost', {})
if not isinstance(draft_post, dict):
raise ValueError("draftPost must be a dict object")
# Validate richContent structure
if 'richContent' in draft_post:
rc = draft_post['richContent']
if not isinstance(rc, dict):
raise ValueError(f"richContent must be a dict, got {type(rc)}")
if 'nodes' not in rc:
raise ValueError("richContent missing 'nodes' field")
if not isinstance(rc['nodes'], list):
raise ValueError(f"richContent.nodes must be a list, got {type(rc['nodes'])}")
logger.debug(f"✅ richContent validation passed: {len(rc.get('nodes', []))} nodes")
# Validate seoData structure if present
if 'seoData' in draft_post:
seo = draft_post['seoData']
if not isinstance(seo, dict):
raise ValueError(f"seoData must be a dict, got {type(seo)}")
if 'tags' in seo and not isinstance(seo['tags'], list):
raise ValueError(f"seoData.tags must be a list, got {type(seo.get('tags'))}")
if 'settings' in seo and not isinstance(seo['settings'], dict):
raise ValueError(f"seoData.settings must be a dict, got {type(seo.get('settings'))}")
logger.debug(f"✅ seoData validation passed: {len(seo.get('tags', []))} tags")
# Final validation: Ensure no None values in any nested objects
# Wix API rejects None values and expects proper types
try:
validate_payload_no_none(blog_data, "blog_data")
logger.debug("✅ Payload validation passed: No None values found")
except ValueError as e:
logger.error(f"❌ Payload validation failed: {e}")
raise
# Log full payload structure for debugging (sanitized)
logger.warning(f"📦 Full payload structure validation:")
logger.warning(f" - draftPost type: {type(draft_post)}")
logger.warning(f" - draftPost keys: {list(draft_post.keys())}")
logger.warning(f" - richContent type: {type(draft_post.get('richContent'))}")
if 'richContent' in draft_post:
rc = draft_post['richContent']
logger.warning(f" - richContent keys: {list(rc.keys()) if isinstance(rc, dict) else 'N/A'}")
logger.warning(f" - richContent.nodes type: {type(rc.get('nodes'))}, count: {len(rc.get('nodes', []))}")
logger.warning(f" - richContent.metadata type: {type(rc.get('metadata'))}")
logger.warning(f" - richContent.documentStyle type: {type(rc.get('documentStyle'))}")
logger.warning(f" - seoData type: {type(draft_post.get('seoData'))}")
if 'seoData' in draft_post:
seo = draft_post['seoData']
logger.warning(f" - seoData keys: {list(seo.keys()) if isinstance(seo, dict) else 'N/A'}")
logger.warning(f" - seoData.tags type: {type(seo.get('tags'))}, count: {len(seo.get('tags', []))}")
logger.warning(f" - seoData.settings type: {type(seo.get('settings'))}")
if 'categoryIds' in draft_post:
logger.warning(f" - categoryIds type: {type(draft_post.get('categoryIds'))}, count: {len(draft_post.get('categoryIds', []))}")
if 'tagIds' in draft_post:
logger.warning(f" - tagIds type: {type(draft_post.get('tagIds'))}, count: {len(draft_post.get('tagIds', []))}")
# Log a sample of the payload JSON to see exact structure (first 2000 chars)
try:
import json
payload_json = json.dumps(blog_data, indent=2, ensure_ascii=False)
logger.warning(f"📄 Payload JSON preview (first 3000 chars):\n{payload_json[:3000]}...")
# Also log a deep structure inspection of richContent.nodes (first few nodes)
if 'richContent' in blog_data['draftPost']:
nodes = blog_data['draftPost']['richContent'].get('nodes', [])
if nodes:
logger.warning(f"🔍 Inspecting first 5 richContent.nodes:")
for i, node in enumerate(nodes[:5]):
logger.warning(f" Node {i+1}: type={node.get('type')}, keys={list(node.keys())}")
# Check for any None values in node
for key, value in node.items():
if value is None:
logger.error(f" ⚠️ Node {i+1}.{key} is None!")
elif isinstance(value, dict):
for k, v in value.items():
if v is None:
logger.error(f" ⚠️ Node {i+1}.{key}.{k} is None!")
# Deep check: if it's a list-type node, inspect list items
if node.get('type') in ['BULLETED_LIST', 'ORDERED_LIST']:
list_items = node.get('nodes', [])
if list_items:
logger.warning(f" List has {len(list_items)} items, checking first LIST_ITEM:")
first_item = list_items[0]
logger.warning(f" LIST_ITEM keys: {list(first_item.keys())}")
# Verify listItemData is NOT present (correct per Wix API spec)
if 'listItemData' in first_item:
logger.error(f" ❌ LIST_ITEM incorrectly has listItemData!")
else:
logger.debug(f" ✅ LIST_ITEM correctly has no listItemData")
# Check nested PARAGRAPH nodes
nested_nodes = first_item.get('nodes', [])
if nested_nodes:
logger.warning(f" LIST_ITEM has {len(nested_nodes)} nested nodes")
for n_idx, n_node in enumerate(nested_nodes[:2]):
logger.warning(f" Nested node {n_idx+1}: type={n_node.get('type')}, keys={list(n_node.keys())}")
except Exception as e:
logger.warning(f"Could not serialize payload for logging: {e}")
# Note: All node validation is done by validate_ricos_content() which runs earlier
# The recursive validation ensures all required data fields are present at any depth
# Final deep validation: Serialize and deserialize to catch any JSON-serialization issues
# This will raise an error if there are any objects that can't be serialized
try:
import json
test_json = json.dumps(blog_data, ensure_ascii=False)
test_parsed = json.loads(test_json)
logger.debug("✅ Payload JSON serialization test passed")
except (TypeError, ValueError) as e:
logger.error(f"❌ Payload JSON serialization failed: {e}")
raise ValueError(f"Payload contains non-serializable data: {e}")
# Final check: Ensure documentStyle and metadata are valid objects (not None, not empty strings)
rc = blog_data['draftPost']['richContent']
if 'documentStyle' in rc:
doc_style = rc['documentStyle']
if doc_style is None or doc_style == "":
logger.warning("⚠️ documentStyle is None or empty string, removing it")
del rc['documentStyle']
elif not isinstance(doc_style, dict):
logger.warning(f"⚠️ documentStyle is not a dict ({type(doc_style)}), removing it")
del rc['documentStyle']
if 'metadata' in rc:
metadata = rc['metadata']
if metadata is None or metadata == "":
logger.warning("⚠️ metadata is None or empty string, removing it")
del rc['metadata']
elif not isinstance(metadata, dict):
logger.warning(f"⚠️ metadata is not a dict ({type(metadata)}), removing it")
del rc['metadata']
# Check for any None values in critical nested structures
def check_none_in_dict(d, path=""):
"""Recursively check for None values that shouldn't be there"""
issues = []
if isinstance(d, dict):
for key, value in d.items():
current_path = f"{path}.{key}" if path else key
if value is None:
# Some fields can legitimately be None, but most shouldn't
if key not in ['decorations', 'nodeStyle', 'props']:
issues.append(current_path)
elif isinstance(value, dict):
issues.extend(check_none_in_dict(value, current_path))
elif isinstance(value, list):
for i, item in enumerate(value):
if item is None:
issues.append(f"{current_path}[{i}]")
elif isinstance(item, dict):
issues.extend(check_none_in_dict(item, f"{current_path}[{i}]"))
return issues
none_issues = check_none_in_dict(blog_data['draftPost']['richContent'])
if none_issues:
logger.error(f"❌ Found None values in richContent at: {none_issues[:10]}") # Limit to first 10
# Remove None values from critical paths
for issue_path in none_issues[:5]: # Fix first 5
parts = issue_path.split('.')
try:
obj = blog_data['draftPost']['richContent']
for part in parts[:-1]:
if '[' in part:
key, idx = part.split('[')
idx = int(idx.rstrip(']'))
obj = obj[key][idx]
else:
obj = obj[part]
final_key = parts[-1]
if '[' in final_key:
key, idx = final_key.split('[')
idx = int(idx.rstrip(']'))
obj[key][idx] = {}
else:
obj[final_key] = {}
logger.warning(f"Fixed None value at {issue_path}")
except:
pass
# Log the final payload structure one more time before sending
logger.warning(f"📤 Final payload ready - draftPost keys: {list(blog_data['draftPost'].keys())}")
logger.warning(f"📤 RichContent nodes count: {len(blog_data['draftPost']['richContent'].get('nodes', []))}")
logger.warning(f"📤 RichContent has metadata: {bool(blog_data['draftPost']['richContent'].get('metadata'))}")
logger.warning(f"📤 RichContent has documentStyle: {bool(blog_data['draftPost']['richContent'].get('documentStyle'))}")
result = blog_service.create_draft_post(access_token, blog_data, extra_headers or None)
# Log success
draft_post = result.get('draftPost', {})
post_id = draft_post.get('id', 'N/A')
wix_logger.log_operation_result("Create Draft Post", True, result)
logger.success(f"✅ Wix: Blog post created - ID: {post_id}")
return result
except requests.RequestException as e:
logger.error(f"Failed to create blog post: {e}")
if hasattr(e, 'response') and e.response is not None:
logger.error(f"Response body: {e.response.text}")
raise

View File

@@ -0,0 +1,475 @@
import re
import uuid
from typing import Any, Dict, List
def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
"""
Parse inline markdown formatting (bold, italic, links) into Ricos text nodes.
Returns a list of text nodes with decorations.
Handles: **bold**, *italic*, [links](url), `code`, and combinations.
"""
if not text:
return [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {'text': '', 'decorations': []}
}]
nodes = []
# Process text character by character to handle nested/adjacent formatting
# This is more robust than regex for complex cases
i = 0
current_text = ''
current_decorations = []
while i < len(text):
# Check for bold **text** (must come before single * check)
if i < len(text) - 1 and text[i:i+2] == '**':
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
# Find closing **
end_bold = text.find('**', i + 2)
if end_bold != -1:
bold_text = text[i + 2:end_bold]
# Recursively parse the bold text for nested formatting
bold_nodes = parse_markdown_inline(bold_text)
# Add BOLD decoration to all text nodes within
# Per Wix API: decorations are objects with 'type' field, not strings
for node in bold_nodes:
if node['type'] == 'TEXT':
node_decorations = node['textData'].get('decorations', []).copy()
# Check if BOLD decoration already exists
has_bold = any(d.get('type') == 'BOLD' for d in node_decorations if isinstance(d, dict))
if not has_bold:
node_decorations.append({'type': 'BOLD'})
node['textData']['decorations'] = node_decorations
nodes.append(node)
i = end_bold + 2
continue
# Check for link [text](url)
elif text[i] == '[':
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
current_decorations = []
# Find matching ]
link_end = text.find(']', i)
if link_end != -1 and link_end < len(text) - 1 and text[link_end + 1] == '(':
link_text = text[i + 1:link_end]
url_start = link_end + 2
url_end = text.find(')', url_start)
if url_end != -1:
url = text[url_start:url_end]
# Per Wix API: Links are decorations on TEXT nodes, not separate node types
# Create TEXT node with LINK decoration
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': link_text,
'decorations': [{
'type': 'LINK',
'linkData': {
'link': {
'url': url,
'target': 'BLANK' # Wix API uses 'BLANK', not '_blank'
}
}
}]
}
})
i = url_end + 1
continue
# Check for code `text`
elif text[i] == '`':
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
current_decorations = []
# Find closing `
code_end = text.find('`', i + 1)
if code_end != -1:
code_text = text[i + 1:code_end]
# Per Wix API: CODE is not a valid decoration type, but we'll keep the structure
# Note: Wix uses CODE_BLOCK nodes for code, not CODE decorations
# For inline code, we'll just use plain text for now
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': code_text,
'decorations': [] # CODE is not a valid decoration in Wix API
}
})
i = code_end + 1
continue
# Check for italic *text* (only if not part of **)
elif text[i] == '*' and (i == 0 or text[i-1] != '*') and (i == len(text) - 1 or text[i+1] != '*'):
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
current_decorations = []
# Find closing * (but not **)
italic_end = text.find('*', i + 1)
if italic_end != -1:
# Make sure it's not part of **
if italic_end == len(text) - 1 or text[italic_end + 1] != '*':
italic_text = text[i + 1:italic_end]
italic_nodes = parse_markdown_inline(italic_text)
# Add ITALIC decoration
# Per Wix API: decorations are objects with 'type' field
for node in italic_nodes:
if node['type'] == 'TEXT':
node_decorations = node['textData'].get('decorations', []).copy()
# Check if ITALIC decoration already exists
has_italic = any(d.get('type') == 'ITALIC' for d in node_decorations if isinstance(d, dict))
if not has_italic:
node_decorations.append({'type': 'ITALIC'})
node['textData']['decorations'] = node_decorations
nodes.append(node)
i = italic_end + 1
continue
# Regular character
current_text += text[i]
i += 1
# Add any remaining text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
# If no nodes created, return single plain text node
if not nodes:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': text,
'decorations': []
}
})
return nodes
def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str, Any]:
"""
Convert markdown content into valid Ricos JSON format.
Supports headings, paragraphs, lists, bold, italic, links, and images.
"""
if not content:
content = "This is a post from ALwrity."
nodes = []
lines = content.split('\n')
i = 0
while i < len(lines):
line = lines[i].strip()
if not line:
i += 1
continue
node_id = str(uuid.uuid4())
# Check for headings
if line.startswith('#'):
level = len(line) - len(line.lstrip('#'))
heading_text = line.lstrip('# ').strip()
text_nodes = parse_markdown_inline(heading_text)
nodes.append({
'id': node_id,
'type': 'HEADING',
'nodes': text_nodes,
'headingData': {'level': min(level, 6)}
})
i += 1
# Check for blockquotes
elif line.startswith('>'):
quote_text = line.lstrip('> ').strip()
# Continue reading consecutive blockquote lines
quote_lines = [quote_text]
i += 1
while i < len(lines) and lines[i].strip().startswith('>'):
quote_lines.append(lines[i].strip().lstrip('> ').strip())
i += 1
quote_content = ' '.join(quote_lines)
text_nodes = parse_markdown_inline(quote_content)
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within BLOCKQUOTE
paragraph_node = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
'paragraphData': {}
}
blockquote_node = {
'id': node_id,
'type': 'BLOCKQUOTE',
'nodes': [paragraph_node],
'blockquoteData': {}
}
nodes.append(blockquote_node)
# Check for unordered lists (handle both '- ' and '* ' markers)
elif (line.startswith('- ') or line.startswith('* ') or
(line.startswith('-') and len(line) > 1 and line[1] != '-') or
(line.startswith('*') and len(line) > 1 and line[1] != '*')):
list_items = []
list_marker = '- ' if line.startswith('-') else '* '
# Process list items
while i < len(lines):
current_line = lines[i].strip()
# Check if this is a list item
is_list_item = (current_line.startswith('- ') or current_line.startswith('* ') or
(current_line.startswith('-') and len(current_line) > 1 and current_line[1] != '-') or
(current_line.startswith('*') and len(current_line) > 1 and current_line[1] != '*'))
if not is_list_item:
break
# Extract item text (handle both '- ' and '-item' formats)
if current_line.startswith('- ') or current_line.startswith('* '):
item_text = current_line[2:].strip()
elif current_line.startswith('-'):
item_text = current_line[1:].strip()
elif current_line.startswith('*'):
item_text = current_line[1:].strip()
else:
item_text = current_line
list_items.append(item_text)
i += 1
# Check for nested items (indented with 2+ spaces)
while i < len(lines):
next_line = lines[i]
# Must be indented and be a list marker
if next_line.startswith(' ') and (next_line.strip().startswith('- ') or
next_line.strip().startswith('* ') or
(next_line.strip().startswith('-') and len(next_line.strip()) > 1) or
(next_line.strip().startswith('*') and len(next_line.strip()) > 1)):
nested_text = next_line.strip()
if nested_text.startswith('- ') or nested_text.startswith('* '):
nested_text = nested_text[2:].strip()
elif nested_text.startswith('-'):
nested_text = nested_text[1:].strip()
elif nested_text.startswith('*'):
nested_text = nested_text[1:].strip()
list_items.append(nested_text)
i += 1
else:
break
# Build list items with proper formatting
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within LIST_ITEM
# NOTE: LIST_ITEM nodes do NOT have a data field per Wix API schema
# Wix API: omit empty data objects, don't include them as {}
list_node_items = []
for item_text in list_items:
item_node_id = str(uuid.uuid4())
text_nodes = parse_markdown_inline(item_text)
paragraph_node = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
'paragraphData': {}
}
list_item_node = {
'id': item_node_id,
'type': 'LIST_ITEM',
'nodes': [paragraph_node]
}
list_node_items.append(list_item_node)
bulleted_list_node = {
'id': node_id,
'type': 'BULLETED_LIST',
'nodes': list_node_items,
'bulletedListData': {}
}
nodes.append(bulleted_list_node)
# Check for ordered lists
elif re.match(r'^\d+\.\s+', line):
list_items = []
while i < len(lines) and re.match(r'^\d+\.\s+', lines[i].strip()):
item_text = re.sub(r'^\d+\.\s+', '', lines[i].strip())
list_items.append(item_text)
i += 1
# Check for nested items
while i < len(lines) and lines[i].strip().startswith(' ') and re.match(r'^\s+\d+\.\s+', lines[i].strip()):
nested_text = re.sub(r'^\s+\d+\.\s+', '', lines[i].strip())
list_items.append(nested_text)
i += 1
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within LIST_ITEM
# NOTE: LIST_ITEM nodes do NOT have a data field per Wix API schema
# Wix API: omit empty data objects, don't include them as {}
list_node_items = []
for item_text in list_items:
item_node_id = str(uuid.uuid4())
text_nodes = parse_markdown_inline(item_text)
paragraph_node = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
'paragraphData': {}
}
list_item_node = {
'id': item_node_id,
'type': 'LIST_ITEM',
'nodes': [paragraph_node]
}
list_node_items.append(list_item_node)
ordered_list_node = {
'id': node_id,
'type': 'ORDERED_LIST',
'nodes': list_node_items,
'orderedListData': {}
}
nodes.append(ordered_list_node)
# Check for images
elif line.startswith('!['):
img_match = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', line)
if img_match:
alt_text = img_match.group(1)
img_url = img_match.group(2)
nodes.append({
'id': node_id,
'type': 'IMAGE',
'nodes': [],
'imageData': {
'image': {
'src': {'url': img_url},
'altText': alt_text
},
'containerData': {
'alignment': 'CENTER',
'width': {'size': 'CONTENT'}
}
}
})
i += 1
# Regular paragraph
else:
# Collect consecutive non-empty lines as paragraph content
para_lines = [line]
i += 1
while i < len(lines):
next_line = lines[i].strip()
if not next_line:
break
# Stop if next line is a special markdown element
if (next_line.startswith('#') or
next_line.startswith('- ') or
next_line.startswith('* ') or
next_line.startswith('>') or
next_line.startswith('![') or
re.match(r'^\d+\.\s+', next_line)):
break
para_lines.append(next_line)
i += 1
para_text = ' '.join(para_lines)
text_nodes = parse_markdown_inline(para_text)
# Only add paragraph if there are text nodes
if text_nodes:
paragraph_node = {
'id': node_id,
'type': 'PARAGRAPH',
'nodes': text_nodes,
'paragraphData': {}
}
nodes.append(paragraph_node)
# Ensure at least one node exists
# Wix API: omit empty data objects, don't include them as {}
if not nodes:
fallback_paragraph = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': content[:500] if content else "This is a post from ALwrity.",
'decorations': []
}
}],
'paragraphData': {}
}
nodes.append(fallback_paragraph)
# Per Wix Blog API documentation: richContent should ONLY contain 'nodes'
# Do NOT include 'type', 'id', 'metadata', or 'documentStyle' at root level
# These fields are for Ricos Document format, but Blog API expects just the nodes structure
return {
'nodes': nodes
}

View File

@@ -0,0 +1,118 @@
"""
Intelligent logging utility for Wix operations.
Aggregates and consolidates logs to reduce console noise.
"""
from typing import Dict, Any, Optional, List
from loguru import logger
import json
class WixLogger:
"""Consolidated logger for Wix operations"""
def __init__(self):
self.context: Dict[str, Any] = {}
self.errors: List[str] = []
self.warnings: List[str] = []
def reset(self):
"""Reset context for new operation"""
self.context = {}
self.errors = []
self.warnings = []
def set_context(self, key: str, value: Any):
"""Store context information"""
self.context[key] = value
def add_error(self, message: str):
"""Add error message"""
self.errors.append(message)
def add_warning(self, message: str):
"""Add warning message"""
self.warnings.append(message)
def log_operation_start(self, operation: str, **kwargs):
"""Log start of operation with aggregated context"""
logger.info(f"🚀 Wix: {operation}")
if kwargs:
summary = ", ".join([f"{k}={v}" for k, v in kwargs.items() if v])
if summary:
logger.info(f" {summary}")
def log_operation_result(self, operation: str, success: bool, result: Optional[Dict] = None, error: Optional[str] = None):
"""Log operation result"""
if success:
post_id = result.get('draftPost', {}).get('id') if result else None
if post_id:
logger.success(f"✅ Wix: {operation} - Post ID: {post_id}")
else:
logger.success(f"✅ Wix: {operation} - Success")
else:
logger.error(f"❌ Wix: {operation} - {error or 'Failed'}")
def log_api_call(self, method: str, endpoint: str, status_code: int,
payload_summary: Optional[Dict] = None, error_body: Optional[Dict] = None):
"""Log API call with aggregated information"""
status_emoji = "" if status_code < 400 else ""
logger.info(f"{status_emoji} Wix API: {method} {endpoint}{status_code}")
if payload_summary:
# Show only key information
if 'draftPost' in payload_summary:
dp = payload_summary['draftPost']
parts = []
if 'title' in dp:
parts.append(f"title='{str(dp['title'])[:50]}...'")
if 'richContent' in dp:
nodes_count = len(dp['richContent'].get('nodes', []))
parts.append(f"nodes={nodes_count}")
if 'seoData' in dp:
parts.append("has_seoData")
if parts:
logger.debug(f" Payload: {', '.join(parts)}")
if error_body and status_code >= 400:
error_msg = error_body.get('message', 'Unknown error')
logger.error(f" Error: {error_msg}")
if status_code == 500:
logger.error(" ⚠️ Internal server error - check Wix API status")
elif status_code == 403:
logger.error(" ⚠️ Permission denied - verify OAuth app has BLOG.CREATE-DRAFT")
elif status_code == 401:
logger.error(" ⚠️ Unauthorized - token may be expired")
def log_token_info(self, token_length: int, has_blog_scope: Optional[bool] = None,
meta_site_id: Optional[str] = None):
"""Log token information (aggregated)"""
info_parts = [f"Token: {token_length} chars"]
if has_blog_scope is not None:
info_parts.append(f"Blog scope: {'' if has_blog_scope else ''}")
if meta_site_id:
info_parts.append(f"Site ID: {meta_site_id[:20]}...")
logger.debug(f"🔐 Wix Auth: {', '.join(info_parts)}")
def log_ricos_conversion(self, nodes_count: int, method: str = "custom parser"):
"""Log Ricos conversion result"""
logger.info(f"📝 Wix Ricos: Converted to {nodes_count} nodes ({method})")
def log_seo_data(self, tags_count: int, keywords_count: int):
"""Log SEO data summary"""
logger.info(f"🔍 Wix SEO: {tags_count} tags, {keywords_count} keywords")
def log_final_summary(self):
"""Log final aggregated summary"""
if self.errors:
logger.error(f"⚠️ Wix Operation: {len(self.errors)} error(s)")
for err in self.errors[-3:]: # Show last 3 errors
logger.error(f" {err}")
elif self.warnings:
logger.warning(f"⚠️ Wix Operation: {len(self.warnings)} warning(s)")
else:
logger.success("✅ Wix Operation: No issues detected")
# Global instance
wix_logger = WixLogger()

View File

@@ -0,0 +1,31 @@
from typing import Any, Dict
import requests
class WixMediaService:
def __init__(self, base_url: str):
self.base_url = base_url
def import_image(self, access_token: str, image_url: str, display_name: str) -> Dict[str, Any]:
"""
Import external image to Wix Media Manager.
Official endpoint: https://www.wixapis.com/site-media/v1/files/import
Reference: https://dev.wix.com/docs/rest/assets/media/media-manager/files/import-file
"""
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
}
payload = {
'url': image_url,
'mediaType': 'IMAGE',
'displayName': display_name,
}
# Correct endpoint per Wix API documentation
endpoint = f"{self.base_url}/site-media/v1/files/import"
response = requests.post(endpoint, headers=headers, json=payload)
response.raise_for_status()
return response.json()

View File

@@ -0,0 +1,302 @@
"""
Ricos Document Converter for Wix
Converts markdown content to Wix Ricos JSON format using either:
1. Wix's official Ricos Documents API (preferred)
2. Custom markdown parser (fallback)
"""
import json
import requests
import jwt
from typing import Dict, Any, Optional
from loguru import logger
def markdown_to_html(markdown_content: str) -> str:
"""
Convert markdown content to HTML.
Uses a simple markdown parser for basic conversion.
Args:
markdown_content: Markdown content to convert
Returns:
HTML string
"""
try:
# Try using markdown library if available
import markdown
html = markdown.markdown(markdown_content, extensions=['fenced_code', 'tables'])
return html
except ImportError:
# Fallback: Simple regex-based conversion for basic markdown
logger.warning("markdown library not available, using basic markdown-to-HTML conversion")
import re
if not markdown_content or not markdown_content.strip():
return "<p>This is a post from ALwrity.</p>"
lines = markdown_content.split('\n')
result = []
in_list = False
list_type = None # 'ul' or 'ol'
in_code_block = False
code_block_content = []
i = 0
while i < len(lines):
line = lines[i].strip()
# Handle code blocks first
if line.startswith('```'):
if not in_code_block:
in_code_block = True
code_block_content = []
i += 1
continue
else:
in_code_block = False
result.append(f'<pre><code>{"\n".join(code_block_content)}</code></pre>')
code_block_content = []
i += 1
continue
if in_code_block:
code_block_content.append(lines[i])
i += 1
continue
# Close any open lists
if in_list and not (line.startswith('- ') or line.startswith('* ') or re.match(r'^\d+\.\s+', line)):
result.append(f'</{list_type}>')
in_list = False
list_type = None
if not line:
i += 1
continue
# Headers
if line.startswith('###'):
result.append(f'<h3>{line[3:].strip()}</h3>')
elif line.startswith('##'):
result.append(f'<h2>{line[2:].strip()}</h2>')
elif line.startswith('#'):
result.append(f'<h1>{line[1:].strip()}</h1>')
# Lists
elif line.startswith('- ') or line.startswith('* '):
if not in_list or list_type != 'ul':
if in_list:
result.append(f'</{list_type}>')
result.append('<ul>')
in_list = True
list_type = 'ul'
# Process inline formatting in list item
item_text = line[2:].strip()
item_text = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', item_text)
item_text = re.sub(r'\*(.*?)\*', r'<em>\1</em>', item_text)
result.append(f'<li>{item_text}</li>')
elif re.match(r'^\d+\.\s+', line):
if not in_list or list_type != 'ol':
if in_list:
result.append(f'</{list_type}>')
result.append('<ol>')
in_list = True
list_type = 'ol'
# Process inline formatting in list item
match = re.match(r'^\d+\.\s+(.*)', line)
if match:
item_text = match.group(1)
item_text = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', item_text)
item_text = re.sub(r'\*(.*?)\*', r'<em>\1</em>', item_text)
result.append(f'<li>{item_text}</li>')
# Blockquotes
elif line.startswith('>'):
quote_text = line[1:].strip()
quote_text = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', quote_text)
quote_text = re.sub(r'\*(.*?)\*', r'<em>\1</em>', quote_text)
result.append(f'<blockquote><p>{quote_text}</p></blockquote>')
# Regular paragraphs
else:
para_text = line
# Process inline formatting
para_text = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', para_text)
para_text = re.sub(r'\*(.*?)\*', r'<em>\1</em>', para_text)
para_text = re.sub(r'\[([^\]]+)\]\(([^\)]+)\)', r'<a href="\2">\1</a>', para_text)
para_text = re.sub(r'`([^`]+)`', r'<code>\1</code>', para_text)
result.append(f'<p>{para_text}</p>')
i += 1
# Close any open lists
if in_list:
result.append(f'</{list_type}>')
# Ensure we have at least one paragraph
if not result:
result.append('<p>This is a post from ALwrity.</p>')
html = '\n'.join(result)
logger.debug(f"Converted {len(markdown_content)} chars markdown to {len(html)} chars HTML")
return html
def convert_via_wix_api(markdown_content: str, access_token: str, base_url: str = 'https://www.wixapis.com') -> Dict[str, Any]:
"""
Convert markdown to Ricos using Wix's official Ricos Documents API.
Uses HTML format for better reliability (per Wix documentation, HTML is fully supported).
Wix API Limitation: HTML content must be 10,000 characters or less.
If content exceeds this limit, it will be truncated with an ellipsis.
Reference: https://dev.wix.com/docs/api-reference/assets/rich-content/ricos-documents/convert-to-ricos-document
Args:
markdown_content: Markdown content to convert (will be converted to HTML)
access_token: Wix access token
base_url: Wix API base URL (default: https://www.wixapis.com)
Returns:
Ricos JSON document
"""
# Validate content is not empty
markdown_stripped = markdown_content.strip() if markdown_content else ""
if not markdown_stripped:
logger.error("Markdown content is empty or whitespace-only")
raise ValueError("Content cannot be empty for Wix Ricos API conversion")
logger.debug(f"Converting markdown to HTML: input_length={len(markdown_stripped)} chars")
# Convert markdown to HTML for better reliability with Wix API
# HTML format is more structured and less prone to parsing errors
html_content = markdown_to_html(markdown_stripped)
# Validate HTML content is not empty - CRITICAL for Wix API
html_stripped = html_content.strip() if html_content else ""
if not html_stripped or len(html_stripped) == 0:
logger.error(f"HTML conversion produced empty content! Markdown length: {len(markdown_stripped)}")
logger.error(f"Markdown sample: {markdown_stripped[:500]}...")
logger.error(f"HTML result: '{html_content}' (type: {type(html_content)})")
# Fallback: use a minimal valid HTML if conversion failed
html_content = "<p>Content from ALwrity blog writer.</p>"
logger.warning("Using fallback HTML due to empty conversion result")
else:
html_content = html_stripped
# CRITICAL: Wix API has a 10,000 character limit for HTML content
# If content exceeds this limit, truncate intelligently at paragraph boundaries
MAX_HTML_LENGTH = 10000
if len(html_content) > MAX_HTML_LENGTH:
logger.warning(f"⚠️ HTML content ({len(html_content)} chars) exceeds Wix API limit of {MAX_HTML_LENGTH} chars")
# Try to truncate at a paragraph boundary to avoid breaking HTML tags
truncate_at = MAX_HTML_LENGTH - 100 # Leave room for closing tags and ellipsis
# Look for the last </p> tag before the truncation point
last_p_close = html_content.rfind('</p>', 0, truncate_at)
if last_p_close > 0:
html_content = html_content[:last_p_close + 4] # Include the </p> tag
else:
# If no paragraph boundary found, just truncate
html_content = html_content[:truncate_at]
# Add an ellipsis paragraph to indicate truncation
html_content += '<p><em>... (Content truncated due to length constraints)</em></p>'
logger.warning(f"✅ Truncated HTML to {len(html_content)} chars (at paragraph boundary)")
logger.debug(f"✅ Converted markdown to HTML: {len(html_content)} chars, preview: {html_content[:200]}...")
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
# Add wix-site-id if available from token
try:
token_str = str(access_token)
if token_str and token_str.startswith('OauthNG.JWS.'):
jwt_part = token_str[12:]
payload = jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
data_payload = payload.get('data', {})
if isinstance(data_payload, str):
try:
data_payload = json.loads(data_payload)
except:
pass
instance_data = data_payload.get('instance', {})
meta_site_id = instance_data.get('metaSiteId')
if isinstance(meta_site_id, str) and meta_site_id:
headers['wix-site-id'] = meta_site_id
except Exception as e:
logger.debug(f"Could not extract site ID from token: {e}")
# Call Wix Ricos Documents API: Convert to Ricos Document
# Official endpoint: https://www.wixapis.com/ricos/v1/ricos-document/convert/to-ricos
# Reference: https://dev.wix.com/docs/rest/assets/rich-content/ricos-documents/convert-to-ricos-document
endpoint = f"{base_url}/ricos/v1/ricos-document/convert/to-ricos"
# Ensure HTML content is not empty or just whitespace
html_stripped = html_content.strip() if html_content else ""
if not html_stripped or len(html_stripped) == 0:
logger.error(f"HTML content is empty after conversion. Markdown length: {len(markdown_content)}")
logger.error(f"Markdown preview (first 500 chars): {markdown_content[:500] if markdown_content else 'N/A'}")
raise ValueError(f"HTML content cannot be empty. Original markdown had {len(markdown_content)} characters.")
# Payload structure per Wix API: html/markdown/plainText field at root, optional plugins
payload = {
'html': html_stripped, # Direct field, not nested in options
'plugins': [] # Optional: empty array uses default plugins
}
logger.warning(f"📤 Sending to Wix Ricos API: html_length={len(payload['html'])}, plugins_count={len(payload['plugins'])}")
logger.debug(f"HTML preview (first 300 chars): {html_stripped[:300]}...")
try:
# Log the exact payload being sent (for debugging)
logger.warning(f"📤 Wix Ricos API Request:")
logger.warning(f" Endpoint: {endpoint}")
logger.warning(f" Payload keys: {list(payload.keys())}")
logger.warning(f" HTML length: {len(payload.get('html', ''))}")
logger.warning(f" Plugins: {payload.get('plugins', [])}")
logger.debug(f" Full payload (first 500 chars of HTML): {str(payload)[:500]}")
response = requests.post(
endpoint,
headers=headers,
json=payload,
timeout=30
)
response.raise_for_status()
result = response.json()
# Extract the ricos document from response
# Response structure: { "document": { "nodes": [...], "metadata": {...}, "documentStyle": {...} } }
ricos_document = result.get('document')
if not ricos_document:
# Fallback: try other possible response fields
ricos_document = result.get('ricosDocument') or result.get('ricos') or result
if not ricos_document:
logger.error(f"Unexpected response structure from Wix API: {list(result.keys())}")
logger.error(f"Response: {result}")
raise ValueError("Wix API did not return a valid Ricos document")
logger.warning(f"✅ Successfully converted HTML to Ricos via Wix API: {len(ricos_document.get('nodes', []))} nodes")
return ricos_document
except requests.RequestException as e:
logger.error(f"❌ Wix Ricos API conversion failed: {e}")
if hasattr(e, 'response') and e.response is not None:
logger.error(f" Response status: {e.response.status_code}")
logger.error(f" Response headers: {dict(e.response.headers)}")
try:
error_body = e.response.json()
logger.error(f" Response JSON: {error_body}")
except:
logger.error(f" Response text: {e.response.text}")
logger.error(f" Request payload was: {json.dumps(payload, indent=2)[:1000]}...") # First 1000 chars
raise

View File

@@ -0,0 +1,311 @@
"""
SEO Data Builder for Wix Blog Posts
Builds Wix-compatible seoData objects from ALwrity SEO metadata.
"""
from typing import Dict, Any, Optional
from loguru import logger
def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> Optional[Dict[str, Any]]:
"""
Build Wix seoData object from our SEO metadata format.
Args:
seo_metadata: SEO metadata dict with fields like:
- seo_title: SEO optimized title
- meta_description: Meta description
- focus_keyword: Main keyword
- blog_tags: List of tag strings (for keywords)
- open_graph: Open Graph data dict
- canonical_url: Canonical URL
default_title: Fallback title if seo_title not provided
Returns:
Wix seoData object with settings.keywords and tags array, or None if empty
"""
seo_data = {
'settings': {
'keywords': [],
'preventAutoRedirect': False # Required by Wix API schema
},
'tags': []
}
# Build keywords array
keywords_list = []
# Add main keyword (focus_keyword) if provided
focus_keyword = seo_metadata.get('focus_keyword')
if focus_keyword:
keywords_list.append({
'term': str(focus_keyword),
'isMain': True,
'origin': 'USER' # Required by Wix API
})
# Add additional keywords from blog_tags or other sources
blog_tags = seo_metadata.get('blog_tags', [])
if isinstance(blog_tags, list):
for tag in blog_tags:
tag_str = str(tag).strip()
if tag_str and tag_str != focus_keyword: # Don't duplicate main keyword
keywords_list.append({
'term': tag_str,
'isMain': False,
'origin': 'USER' # Required by Wix API
})
# Add social hashtags as keywords if available
social_hashtags = seo_metadata.get('social_hashtags', [])
if isinstance(social_hashtags, list):
for hashtag in social_hashtags:
# Remove # if present
hashtag_str = str(hashtag).strip().lstrip('#')
if hashtag_str and hashtag_str != focus_keyword:
keywords_list.append({
'term': hashtag_str,
'isMain': False,
'origin': 'USER' # Required by Wix API
})
# CRITICAL: Wix Blog API limits keywords to maximum 5
# Prioritize: main keyword first, then most important additional keywords
if len(keywords_list) > 5:
logger.warning(f"Truncating keywords from {len(keywords_list)} to 5 (Wix API limit)")
# Keep main keyword + next 4 most important
keywords_list = keywords_list[:5]
seo_data['settings']['keywords'] = keywords_list
# Validate keywords list is not empty (or ensure at least one keyword exists)
if not seo_data['settings']['keywords']:
logger.warning("No keywords found in SEO metadata, adding empty keywords array")
# Build tags array (meta tags, Open Graph, etc.)
tags_list = []
# Meta description
meta_description = seo_metadata.get('meta_description')
if meta_description:
tags_list.append({
'type': 'meta',
'props': {
'name': 'description',
'content': str(meta_description)
},
'custom': True,
'disabled': False
})
# SEO title - 'title' type uses 'children' field, not 'props.content'
# Per Wix API example: title tags don't need 'custom' or 'disabled' fields
seo_title = seo_metadata.get('seo_title') or default_title
if seo_title:
tags_list.append({
'type': 'title',
'children': str(seo_title) # Title tags use 'children', not 'props.content'
# Note: Wix example doesn't show 'custom' or 'disabled' for title tags
})
# Open Graph tags
open_graph = seo_metadata.get('open_graph', {})
if isinstance(open_graph, dict):
# OG Title
og_title = open_graph.get('title') or seo_title
if og_title:
tags_list.append({
'type': 'meta',
'props': {
'property': 'og:title',
'content': str(og_title)
},
'custom': True,
'disabled': False
})
# OG Description
og_description = open_graph.get('description') or meta_description
if og_description:
tags_list.append({
'type': 'meta',
'props': {
'property': 'og:description',
'content': str(og_description)
},
'custom': True,
'disabled': False
})
# OG Image
og_image = open_graph.get('image')
if og_image:
# Skip base64 images for OG tags (Wix needs URLs)
if isinstance(og_image, str) and (og_image.startswith('http://') or og_image.startswith('https://')):
tags_list.append({
'type': 'meta',
'props': {
'property': 'og:image',
'content': og_image
},
'custom': True,
'disabled': False
})
# OG Type
tags_list.append({
'type': 'meta',
'props': {
'property': 'og:type',
'content': 'article'
},
'custom': True,
'disabled': False
})
# OG URL (canonical or provided URL)
og_url = open_graph.get('url') or seo_metadata.get('canonical_url')
if og_url:
tags_list.append({
'type': 'meta',
'props': {
'property': 'og:url',
'content': str(og_url)
},
'custom': True,
'disabled': False
})
# Twitter Card tags
twitter_card = seo_metadata.get('twitter_card', {})
if isinstance(twitter_card, dict):
twitter_title = twitter_card.get('title') or seo_title
if twitter_title:
tags_list.append({
'type': 'meta',
'props': {
'name': 'twitter:title',
'content': str(twitter_title)
},
'custom': True,
'disabled': False
})
twitter_description = twitter_card.get('description') or meta_description
if twitter_description:
tags_list.append({
'type': 'meta',
'props': {
'name': 'twitter:description',
'content': str(twitter_description)
},
'custom': True,
'disabled': False
})
twitter_image = twitter_card.get('image')
if twitter_image and isinstance(twitter_image, str) and (twitter_image.startswith('http://') or twitter_image.startswith('https://')):
tags_list.append({
'type': 'meta',
'props': {
'name': 'twitter:image',
'content': twitter_image
},
'custom': True,
'disabled': False
})
twitter_card_type = twitter_card.get('card', 'summary_large_image')
tags_list.append({
'type': 'meta',
'props': {
'name': 'twitter:card',
'content': str(twitter_card_type)
},
'custom': True,
'disabled': False
})
# Canonical URL as link tag
canonical_url = seo_metadata.get('canonical_url')
if canonical_url:
tags_list.append({
'type': 'link',
'props': {
'rel': 'canonical',
'href': str(canonical_url)
},
'custom': True,
'disabled': False
})
# Validate all tags have required fields before adding
validated_tags = []
for tag in tags_list:
if not isinstance(tag, dict):
logger.warning(f"Skipping invalid tag (not a dict): {type(tag)}")
continue
# Ensure required fields exist
if 'type' not in tag:
logger.warning("Skipping tag missing 'type' field")
continue
# Ensure 'custom' and 'disabled' fields exist
if 'custom' not in tag:
tag['custom'] = True
if 'disabled' not in tag:
tag['disabled'] = False
# Validate tag structure based on type
tag_type = tag.get('type')
if tag_type == 'title':
if 'children' not in tag or not tag['children']:
logger.warning("Skipping title tag with missing/invalid 'children' field")
continue
elif tag_type == 'meta':
if 'props' not in tag or not isinstance(tag['props'], dict):
logger.warning("Skipping meta tag with missing/invalid 'props' field")
continue
if 'name' not in tag['props'] and 'property' not in tag['props']:
logger.warning("Skipping meta tag with missing 'name' or 'property' in props")
continue
# Ensure 'content' exists and is not empty
if 'content' not in tag['props'] or not str(tag['props'].get('content', '')).strip():
logger.warning(f"Skipping meta tag with missing/empty 'content': {tag.get('props', {})}")
continue
elif tag_type == 'link':
if 'props' not in tag or not isinstance(tag['props'], dict):
logger.warning("Skipping link tag with missing/invalid 'props' field")
continue
# Ensure 'href' exists and is not empty for link tags
if 'href' not in tag['props'] or not str(tag['props'].get('href', '')).strip():
logger.warning(f"Skipping link tag with missing/empty 'href': {tag.get('props', {})}")
continue
validated_tags.append(tag)
seo_data['tags'] = validated_tags
# Final validation: ensure seoData structure is complete
if not isinstance(seo_data['settings'], dict):
logger.error("seoData.settings is not a dict, creating default")
seo_data['settings'] = {'keywords': []}
if not isinstance(seo_data['settings'].get('keywords'), list):
logger.error("seoData.settings.keywords is not a list, creating empty list")
seo_data['settings']['keywords'] = []
if not isinstance(seo_data['tags'], list):
logger.error("seoData.tags is not a list, creating empty list")
seo_data['tags'] = []
# CRITICAL: Per Wix API patterns, omit empty structures instead of including them as {}
# If keywords is empty, omit settings entirely
if not seo_data['settings'].get('keywords'):
logger.debug("No keywords found, omitting settings from seoData")
seo_data.pop('settings', None)
logger.debug(f"Built SEO data: {len(validated_tags)} tags, {len(keywords_list)} keywords")
# Only return seoData if we have at least keywords or tags
if keywords_list or validated_tags:
return seo_data
return None

View File

@@ -0,0 +1,109 @@
from typing import Any, Dict, Optional
import jwt
import json
def normalize_token_string(access_token: Any) -> Optional[str]:
try:
if isinstance(access_token, str):
return access_token
if isinstance(access_token, dict):
token_str = access_token.get('access_token') or access_token.get('value')
if token_str:
return token_str
at = access_token.get('accessToken')
if isinstance(at, dict):
return at.get('value')
if isinstance(at, str):
return at
return None
except Exception:
return None
def extract_member_id_from_access_token(access_token: Any) -> Optional[str]:
try:
token_str: Optional[str] = None
if isinstance(access_token, str):
token_str = access_token
elif isinstance(access_token, dict):
token_str = access_token.get('access_token') or access_token.get('value')
if not token_str:
at = access_token.get('accessToken')
if isinstance(at, dict):
token_str = at.get('value')
elif isinstance(at, str):
token_str = at
if not token_str:
return None
if token_str.startswith('OauthNG.JWS.'):
jwt_part = token_str[12:]
data = jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
else:
data = jwt.decode(token_str, options={"verify_signature": False, "verify_aud": False})
data_payload = data.get('data')
if isinstance(data_payload, str):
try:
data_payload = json.loads(data_payload)
except Exception:
pass
if isinstance(data_payload, dict):
instance = data_payload.get('instance', {})
if isinstance(instance, dict):
site_member_id = instance.get('siteMemberId')
if isinstance(site_member_id, str) and site_member_id:
return site_member_id
for key in ['memberId', 'sub', 'authorizedSubject', 'id', 'siteMemberId']:
val = data_payload.get(key)
if isinstance(val, str) and val:
return val
member = data_payload.get('member') or {}
if isinstance(member, dict):
val = member.get('id')
if isinstance(val, str) and val:
return val
for key in ['memberId', 'sub', 'authorizedSubject']:
val = data.get(key)
if isinstance(val, str) and val:
return val
member = data.get('member') or {}
if isinstance(member, dict):
val = member.get('id')
if isinstance(val, str) and val:
return val
return None
except Exception:
return None
def decode_wix_token(access_token: str) -> Dict[str, Any]:
token_str = str(access_token)
if token_str.startswith('OauthNG.JWS.'):
jwt_part = token_str[12:]
return jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
return jwt.decode(token_str, options={"verify_signature": False, "verify_aud": False})
def extract_meta_from_token(access_token: str) -> Dict[str, Optional[str]]:
try:
payload = decode_wix_token(access_token)
data_payload = payload.get('data', {})
if isinstance(data_payload, str):
try:
data_payload = json.loads(data_payload)
except Exception:
pass
instance = (data_payload or {}).get('instance', {})
return {
'siteMemberId': instance.get('siteMemberId'),
'metaSiteId': instance.get('metaSiteId'),
'permissions': instance.get('permissions'),
}
except Exception:
return {'siteMemberId': None, 'metaSiteId': None, 'permissions': None}

View File

@@ -0,0 +1,265 @@
"""
Wix OAuth2 Service
Handles Wix OAuth2 authentication flow and token storage.
"""
import os
import sqlite3
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from loguru import logger
class WixOAuthService:
"""Manages Wix OAuth2 authentication flow and token storage."""
def __init__(self, db_path: str = "alwrity.db"):
self.db_path = db_path
self._init_db()
def _init_db(self):
"""Initialize database tables for OAuth tokens."""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS wix_oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT,
token_type TEXT DEFAULT 'bearer',
expires_at TIMESTAMP,
expires_in INTEGER,
scope TEXT,
site_id TEXT,
member_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE
)
''')
conn.commit()
logger.info("Wix OAuth database initialized.")
def store_tokens(
self,
user_id: str,
access_token: str,
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None,
token_type: str = 'bearer',
scope: Optional[str] = None,
site_id: Optional[str] = None,
member_id: Optional[str] = None
) -> bool:
"""
Store Wix OAuth tokens in the database.
Args:
user_id: User ID (Clerk string)
access_token: Access token from Wix
refresh_token: Optional refresh token
expires_in: Optional expiration time in seconds
token_type: Token type (default: 'bearer')
scope: Optional OAuth scope
site_id: Optional Wix site ID
member_id: Optional Wix member ID
Returns:
True if tokens were stored successfully
"""
try:
expires_at = None
if expires_in:
expires_at = datetime.now() + timedelta(seconds=expires_in)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO wix_oauth_tokens
(user_id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (user_id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id))
conn.commit()
logger.info(f"Wix OAuth: Token inserted into database for user {user_id}")
return True
except Exception as e:
logger.error(f"Error storing Wix tokens for user {user_id}: {e}")
return False
def get_user_tokens(self, user_id: str) -> List[Dict[str, Any]]:
"""Get all active Wix tokens for a user."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id, created_at
FROM wix_oauth_tokens
WHERE user_id = ? AND is_active = TRUE AND (expires_at IS NULL OR expires_at > datetime('now'))
ORDER BY created_at DESC
''', (user_id,))
tokens = []
for row in cursor.fetchall():
tokens.append({
"id": row[0],
"access_token": row[1],
"refresh_token": row[2],
"token_type": row[3],
"expires_at": row[4],
"expires_in": row[5],
"scope": row[6],
"site_id": row[7],
"member_id": row[8],
"created_at": row[9]
})
return tokens
except Exception as e:
logger.error(f"Error getting Wix tokens for user {user_id}: {e}")
return []
def get_user_token_status(self, user_id: str) -> Dict[str, Any]:
"""Get detailed token status for a user including expired tokens."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Get all tokens (active and expired)
cursor.execute('''
SELECT id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id, created_at, is_active
FROM wix_oauth_tokens
WHERE user_id = ?
ORDER BY created_at DESC
''', (user_id,))
all_tokens = []
active_tokens = []
expired_tokens = []
for row in cursor.fetchall():
token_data = {
"id": row[0],
"access_token": row[1],
"refresh_token": row[2],
"token_type": row[3],
"expires_at": row[4],
"expires_in": row[5],
"scope": row[6],
"site_id": row[7],
"member_id": row[8],
"created_at": row[9],
"is_active": bool(row[10])
}
all_tokens.append(token_data)
# Determine expiry using robust parsing and is_active flag
is_active_flag = bool(row[10])
not_expired = False
try:
expires_at_val = row[4]
if expires_at_val:
# First try Python parsing
try:
dt = datetime.fromisoformat(expires_at_val) if isinstance(expires_at_val, str) else expires_at_val
not_expired = dt > datetime.now()
except Exception:
# Fallback to SQLite comparison
cursor.execute("SELECT datetime('now') < ?", (expires_at_val,))
not_expired = cursor.fetchone()[0] == 1
else:
# No expiry stored => consider not expired
not_expired = True
except Exception:
not_expired = False
if is_active_flag and not_expired:
active_tokens.append(token_data)
else:
expired_tokens.append(token_data)
return {
"has_tokens": len(all_tokens) > 0,
"has_active_tokens": len(active_tokens) > 0,
"has_expired_tokens": len(expired_tokens) > 0,
"active_tokens": active_tokens,
"expired_tokens": expired_tokens,
"total_tokens": len(all_tokens),
"last_token_date": all_tokens[0]["created_at"] if all_tokens else None
}
except Exception as e:
logger.error(f"Error getting Wix token status for user {user_id}: {e}")
return {
"has_tokens": False,
"has_active_tokens": False,
"has_expired_tokens": False,
"active_tokens": [],
"expired_tokens": [],
"total_tokens": 0,
"last_token_date": None,
"error": str(e)
}
def update_tokens(
self,
user_id: str,
access_token: str,
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None
) -> bool:
"""Update tokens for a user (e.g., after refresh)."""
try:
expires_at = None
if expires_in:
expires_at = datetime.now() + timedelta(seconds=expires_in)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
if refresh_token:
cursor.execute('''
UPDATE wix_oauth_tokens
SET access_token = ?, refresh_token = ?, expires_at = ?, expires_in = ?,
is_active = TRUE, updated_at = datetime('now')
WHERE user_id = ? AND refresh_token = ?
''', (access_token, refresh_token, expires_at, expires_in, user_id, refresh_token))
else:
cursor.execute('''
UPDATE wix_oauth_tokens
SET access_token = ?, expires_at = ?, expires_in = ?,
is_active = TRUE, updated_at = datetime('now')
WHERE user_id = ? AND id = (SELECT id FROM wix_oauth_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT 1)
''', (access_token, expires_at, expires_in, user_id, user_id))
conn.commit()
logger.info(f"Wix OAuth: Tokens updated for user {user_id}")
return True
except Exception as e:
logger.error(f"Error updating Wix tokens for user {user_id}: {e}")
return False
def revoke_token(self, user_id: str, token_id: int) -> bool:
"""Revoke a Wix OAuth token."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE wix_oauth_tokens
SET is_active = FALSE, updated_at = datetime('now')
WHERE user_id = ? AND id = ?
''', (user_id, token_id))
conn.commit()
if cursor.rowcount > 0:
logger.info(f"Wix token {token_id} revoked for user {user_id}")
return True
return False
except Exception as e:
logger.error(f"Error revoking Wix token: {e}")
return False

View File

@@ -0,0 +1,320 @@
"""
WordPress Content Management Module
Handles content creation, media upload, and publishing to WordPress sites.
"""
import os
import json
import base64
import mimetypes
import tempfile
from typing import Optional, Dict, List, Any, Union
from datetime import datetime
import requests
from requests.auth import HTTPBasicAuth
from PIL import Image
from loguru import logger
class WordPressContentManager:
"""Manages WordPress content operations including posts, media, and taxonomies."""
def __init__(self, site_url: str, username: str, app_password: str):
"""Initialize with WordPress site credentials."""
self.site_url = site_url.rstrip('/')
self.username = username
self.app_password = app_password
self.api_base = f"{self.site_url}/wp-json/wp/v2"
self.auth = HTTPBasicAuth(username, app_password)
def _make_request(self, method: str, endpoint: str, **kwargs) -> Optional[Dict[str, Any]]:
"""Make authenticated request to WordPress API."""
try:
url = f"{self.api_base}/{endpoint.lstrip('/')}"
response = requests.request(method, url, auth=self.auth, **kwargs)
if response.status_code in [200, 201]:
return response.json()
else:
logger.error(f"WordPress API error: {response.status_code} - {response.text}")
return None
except Exception as e:
logger.error(f"WordPress API request error: {e}")
return None
def get_categories(self) -> List[Dict[str, Any]]:
"""Get all categories from WordPress site."""
try:
result = self._make_request('GET', 'categories', params={'per_page': 100})
if result:
logger.info(f"Retrieved {len(result)} categories from {self.site_url}")
return result
return []
except Exception as e:
logger.error(f"Error getting categories: {e}")
return []
def get_tags(self) -> List[Dict[str, Any]]:
"""Get all tags from WordPress site."""
try:
result = self._make_request('GET', 'tags', params={'per_page': 100})
if result:
logger.info(f"Retrieved {len(result)} tags from {self.site_url}")
return result
return []
except Exception as e:
logger.error(f"Error getting tags: {e}")
return []
def create_category(self, name: str, description: str = "") -> Optional[Dict[str, Any]]:
"""Create a new category."""
try:
data = {
'name': name,
'description': description
}
result = self._make_request('POST', 'categories', json=data)
if result:
logger.info(f"Created category: {name}")
return result
except Exception as e:
logger.error(f"Error creating category {name}: {e}")
return None
def create_tag(self, name: str, description: str = "") -> Optional[Dict[str, Any]]:
"""Create a new tag."""
try:
data = {
'name': name,
'description': description
}
result = self._make_request('POST', 'tags', json=data)
if result:
logger.info(f"Created tag: {name}")
return result
except Exception as e:
logger.error(f"Error creating tag {name}: {e}")
return None
def get_or_create_category(self, name: str, description: str = "") -> Optional[int]:
"""Get existing category or create new one."""
try:
# First, try to find existing category
categories = self.get_categories()
for category in categories:
if category['name'].lower() == name.lower():
logger.info(f"Found existing category: {name}")
return category['id']
# Create new category if not found
new_category = self.create_category(name, description)
if new_category:
return new_category['id']
return None
except Exception as e:
logger.error(f"Error getting or creating category {name}: {e}")
return None
def get_or_create_tag(self, name: str, description: str = "") -> Optional[int]:
"""Get existing tag or create new one."""
try:
# First, try to find existing tag
tags = self.get_tags()
for tag in tags:
if tag['name'].lower() == name.lower():
logger.info(f"Found existing tag: {name}")
return tag['id']
# Create new tag if not found
new_tag = self.create_tag(name, description)
if new_tag:
return new_tag['id']
return None
except Exception as e:
logger.error(f"Error getting or creating tag {name}: {e}")
return None
def upload_media(self, file_path: str, alt_text: str = "", title: str = "", caption: str = "", description: str = "") -> Optional[Dict[str, Any]]:
"""Upload media file to WordPress."""
try:
if not os.path.exists(file_path):
logger.error(f"Media file not found: {file_path}")
return None
# Get file info
file_name = os.path.basename(file_path)
mime_type, _ = mimetypes.guess_type(file_path)
if not mime_type:
logger.error(f"Unable to determine MIME type for: {file_path}")
return None
# Prepare headers
headers = {
'Content-Disposition': f'attachment; filename="{file_name}"'
}
# Upload file
with open(file_path, 'rb') as file:
files = {'file': (file_name, file, mime_type)}
response = requests.post(
f"{self.api_base}/media",
auth=self.auth,
headers=headers,
files=files
)
if response.status_code == 201:
media_data = response.json()
media_id = media_data['id']
# Update media with metadata
update_data = {
'alt_text': alt_text,
'title': title,
'caption': caption,
'description': description
}
update_response = requests.post(
f"{self.api_base}/media/{media_id}",
auth=self.auth,
json=update_data
)
if update_response.status_code == 200:
logger.info(f"Media uploaded successfully: {file_name}")
return update_response.json()
else:
logger.warning(f"Media uploaded but metadata update failed: {update_response.text}")
return media_data
else:
logger.error(f"Media upload failed: {response.status_code} - {response.text}")
return None
except Exception as e:
logger.error(f"Error uploading media {file_path}: {e}")
return None
def compress_image(self, image_path: str, quality: int = 85) -> str:
"""Compress image for better upload performance."""
try:
if not os.path.exists(image_path):
raise ValueError(f"Image file not found: {image_path}")
original_size = os.path.getsize(image_path)
with Image.open(image_path) as img:
img_format = img.format or 'JPEG'
# Create temporary file
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=f'.{img_format.lower()}')
# Save with compression
img.save(temp_file, format=img_format, quality=quality, optimize=True)
compressed_size = os.path.getsize(temp_file.name)
reduction = (1 - (compressed_size / original_size)) * 100
logger.info(f"Image compressed: {original_size/1024:.2f}KB -> {compressed_size/1024:.2f}KB ({reduction:.1f}% reduction)")
return temp_file.name
except Exception as e:
logger.error(f"Error compressing image {image_path}: {e}")
return image_path # Return original if compression fails
def _test_connection(self) -> bool:
"""Test WordPress site connection."""
try:
# Test with a simple API call
api_url = f"{self.api_base}/users/me"
response = requests.get(api_url, auth=self.auth, timeout=10)
if response.status_code == 200:
logger.info(f"WordPress connection test successful for {self.site_url}")
return True
else:
logger.warning(f"WordPress connection test failed for {self.site_url}: {response.status_code}")
return False
except Exception as e:
logger.error(f"WordPress connection test error for {self.site_url}: {e}")
return False
def create_post(self, title: str, content: str, excerpt: str = "",
featured_media_id: Optional[int] = None,
categories: Optional[List[int]] = None,
tags: Optional[List[int]] = None,
status: str = 'draft',
meta: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Create a new WordPress post."""
try:
post_data = {
'title': title,
'content': content,
'excerpt': excerpt,
'status': status
}
if featured_media_id:
post_data['featured_media'] = featured_media_id
if categories:
post_data['categories'] = categories
if tags:
post_data['tags'] = tags
if meta:
post_data['meta'] = meta
result = self._make_request('POST', 'posts', json=post_data)
if result:
logger.info(f"Post created successfully: {title}")
return result
except Exception as e:
logger.error(f"Error creating post {title}: {e}")
return None
def update_post(self, post_id: int, **kwargs) -> Optional[Dict[str, Any]]:
"""Update an existing WordPress post."""
try:
result = self._make_request('POST', f'posts/{post_id}', json=kwargs)
if result:
logger.info(f"Post {post_id} updated successfully")
return result
except Exception as e:
logger.error(f"Error updating post {post_id}: {e}")
return None
def get_post(self, post_id: int) -> Optional[Dict[str, Any]]:
"""Get a specific WordPress post."""
try:
result = self._make_request('GET', f'posts/{post_id}')
return result
except Exception as e:
logger.error(f"Error getting post {post_id}: {e}")
return None
def delete_post(self, post_id: int, force: bool = False) -> bool:
"""Delete a WordPress post."""
try:
params = {'force': force} if force else {}
result = self._make_request('DELETE', f'posts/{post_id}', params=params)
if result:
logger.info(f"Post {post_id} deleted successfully")
return True
return False
except Exception as e:
logger.error(f"Error deleting post {post_id}: {e}")
return False

View File

@@ -0,0 +1,375 @@
"""
WordPress OAuth2 Service
Handles WordPress.com OAuth2 authentication flow for simplified user connection.
"""
import os
import secrets
import sqlite3
import requests
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from loguru import logger
import json
import base64
class WordPressOAuthService:
"""Manages WordPress.com OAuth2 authentication flow."""
def __init__(self, db_path: str = "alwrity.db"):
self.db_path = db_path
# WordPress.com OAuth2 credentials
self.client_id = os.getenv('WORDPRESS_CLIENT_ID', '')
self.client_secret = os.getenv('WORDPRESS_CLIENT_SECRET', '')
self.redirect_uri = os.getenv('WORDPRESS_REDIRECT_URI', 'https://alwrity-ai.vercel.app/wp/callback')
self.base_url = "https://public-api.wordpress.com"
# Validate configuration
if not self.client_id or not self.client_secret or self.client_id == 'your_wordpress_com_client_id_here':
logger.error("WordPress OAuth client credentials not configured. Please set WORDPRESS_CLIENT_ID and WORDPRESS_CLIENT_SECRET environment variables with valid WordPress.com application credentials.")
logger.error("To get credentials: 1. Go to https://developer.wordpress.com/apps/ 2. Create a new application 3. Set redirect URI to: https://your-domain.com/wp/callback")
self._init_db()
def _init_db(self):
"""Initialize database tables for OAuth tokens."""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS wordpress_oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT,
token_type TEXT DEFAULT 'bearer',
expires_at TIMESTAMP,
scope TEXT,
blog_id TEXT,
blog_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS wordpress_oauth_states (
id INTEGER PRIMARY KEY AUTOINCREMENT,
state TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP DEFAULT (datetime('now', '+10 minutes'))
)
''')
conn.commit()
logger.info("WordPress OAuth database initialized.")
def generate_authorization_url(self, user_id: str, scope: str = "global") -> Dict[str, Any]:
"""Generate WordPress OAuth2 authorization URL."""
try:
# Check if credentials are properly configured
if not self.client_id or not self.client_secret or self.client_id == 'your_wordpress_com_client_id_here':
logger.error("WordPress OAuth client credentials not configured")
return None
# Generate secure state parameter
state = secrets.token_urlsafe(32)
# Store state in database for validation
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO wordpress_oauth_states (state, user_id)
VALUES (?, ?)
''', (state, user_id))
conn.commit()
# Build authorization URL
# For WordPress.com, use "global" scope for full access to enable posting
params = [
f"client_id={self.client_id}",
f"redirect_uri={self.redirect_uri}",
"response_type=code",
f"state={state}",
f"scope={scope}" # WordPress.com requires "global" scope for full access
]
auth_url = f"{self.base_url}/oauth2/authorize?{'&'.join(params)}"
logger.info(f"Generated WordPress OAuth URL for user {user_id}")
logger.info(f"WordPress OAuth redirect URI: {self.redirect_uri}")
return {
"auth_url": auth_url,
"state": state
}
except Exception as e:
logger.error(f"Error generating WordPress OAuth URL: {e}")
return None
def handle_oauth_callback(self, code: str, state: str) -> Optional[Dict[str, Any]]:
"""Handle OAuth callback and exchange code for access token."""
try:
logger.info(f"WordPress OAuth callback started - code: {code[:20]}..., state: {state[:20]}...")
# Validate state parameter
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT user_id FROM wordpress_oauth_states
WHERE state = ? AND expires_at > datetime('now')
''', (state,))
result = cursor.fetchone()
if not result:
logger.error(f"Invalid or expired state parameter: {state}")
return None
user_id = result[0]
logger.info(f"WordPress OAuth: State validated for user {user_id}")
# Clean up used state
cursor.execute('DELETE FROM wordpress_oauth_states WHERE state = ?', (state,))
conn.commit()
# Exchange authorization code for access token
token_data = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'redirect_uri': self.redirect_uri,
'code': code,
'grant_type': 'authorization_code'
}
logger.info(f"WordPress OAuth: Exchanging code for token...")
response = requests.post(
f"{self.base_url}/oauth2/token",
data=token_data,
timeout=30
)
if response.status_code != 200:
logger.error(f"Token exchange failed: {response.status_code} - {response.text}")
return None
token_info = response.json()
logger.info(f"WordPress OAuth: Token received - blog_id: {token_info.get('blog_id')}, blog_url: {token_info.get('blog_url')}")
# Store token information
access_token = token_info.get('access_token')
blog_id = token_info.get('blog_id')
blog_url = token_info.get('blog_url')
scope = token_info.get('scope', '')
# Calculate expiration (WordPress tokens typically expire in 2 weeks)
expires_at = datetime.now() + timedelta(days=14)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO wordpress_oauth_tokens
(user_id, access_token, token_type, expires_at, scope, blog_id, blog_url)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (user_id, access_token, 'bearer', expires_at, scope, blog_id, blog_url))
conn.commit()
logger.info(f"WordPress OAuth: Token inserted into database for user {user_id}")
logger.info(f"WordPress OAuth token stored successfully for user {user_id}, blog: {blog_url}")
return {
"success": True,
"access_token": access_token,
"blog_id": blog_id,
"blog_url": blog_url,
"scope": scope,
"expires_at": expires_at.isoformat()
}
except Exception as e:
logger.error(f"Error handling WordPress OAuth callback: {e}")
return None
def get_user_tokens(self, user_id: str) -> List[Dict[str, Any]]:
"""Get all active WordPress tokens for a user."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, access_token, token_type, expires_at, scope, blog_id, blog_url, created_at
FROM wordpress_oauth_tokens
WHERE user_id = ? AND is_active = TRUE AND expires_at > datetime('now')
ORDER BY created_at DESC
''', (user_id,))
tokens = []
for row in cursor.fetchall():
tokens.append({
"id": row[0],
"access_token": row[1],
"token_type": row[2],
"expires_at": row[3],
"scope": row[4],
"blog_id": row[5],
"blog_url": row[6],
"created_at": row[7]
})
return tokens
except Exception as e:
logger.error(f"Error getting WordPress tokens for user {user_id}: {e}")
return []
def get_user_token_status(self, user_id: str) -> Dict[str, Any]:
"""Get detailed token status for a user including expired tokens."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Get all tokens (active and expired)
cursor.execute('''
SELECT id, access_token, refresh_token, token_type, expires_at, scope, blog_id, blog_url, created_at, is_active
FROM wordpress_oauth_tokens
WHERE user_id = ?
ORDER BY created_at DESC
''', (user_id,))
all_tokens = []
active_tokens = []
expired_tokens = []
for row in cursor.fetchall():
token_data = {
"id": row[0],
"access_token": row[1],
"refresh_token": row[2],
"token_type": row[3],
"expires_at": row[4],
"scope": row[5],
"blog_id": row[6],
"blog_url": row[7],
"created_at": row[8],
"is_active": bool(row[9])
}
all_tokens.append(token_data)
# Determine expiry using robust parsing and is_active flag
is_active_flag = bool(row[9])
not_expired = False
try:
expires_at_val = row[4]
if expires_at_val:
# First try Python parsing
try:
dt = datetime.fromisoformat(expires_at_val) if isinstance(expires_at_val, str) else expires_at_val
not_expired = dt > datetime.now()
except Exception:
# Fallback to SQLite comparison
cursor.execute("SELECT datetime('now') < ?", (expires_at_val,))
not_expired = cursor.fetchone()[0] == 1
else:
# No expiry stored => consider not expired
not_expired = True
except Exception:
not_expired = False
if is_active_flag and not_expired:
active_tokens.append(token_data)
else:
expired_tokens.append(token_data)
return {
"has_tokens": len(all_tokens) > 0,
"has_active_tokens": len(active_tokens) > 0,
"has_expired_tokens": len(expired_tokens) > 0,
"active_tokens": active_tokens,
"expired_tokens": expired_tokens,
"total_tokens": len(all_tokens),
"last_token_date": all_tokens[0]["created_at"] if all_tokens else None
}
except Exception as e:
logger.error(f"Error getting WordPress token status for user {user_id}: {e}")
return {
"has_tokens": False,
"has_active_tokens": False,
"has_expired_tokens": False,
"active_tokens": [],
"expired_tokens": [],
"total_tokens": 0,
"last_token_date": None,
"error": str(e)
}
def test_token(self, access_token: str) -> bool:
"""Test if a WordPress access token is valid."""
try:
headers = {'Authorization': f'Bearer {access_token}'}
response = requests.get(
f"{self.base_url}/rest/v1/me/",
headers=headers,
timeout=10
)
return response.status_code == 200
except Exception as e:
logger.error(f"Error testing WordPress token: {e}")
return False
def revoke_token(self, user_id: str, token_id: int) -> bool:
"""Revoke a WordPress OAuth token."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE wordpress_oauth_tokens
SET is_active = FALSE, updated_at = datetime('now')
WHERE user_id = ? AND id = ?
''', (user_id, token_id))
conn.commit()
if cursor.rowcount > 0:
logger.info(f"WordPress token {token_id} revoked for user {user_id}")
return True
return False
except Exception as e:
logger.error(f"Error revoking WordPress token: {e}")
return False
def get_connection_status(self, user_id: str) -> Dict[str, Any]:
"""Get WordPress connection status for a user."""
try:
tokens = self.get_user_tokens(user_id)
if not tokens:
return {
"connected": False,
"sites": [],
"total_sites": 0
}
# Test each token and get site information
active_sites = []
for token in tokens:
if self.test_token(token["access_token"]):
active_sites.append({
"id": token["id"],
"blog_id": token["blog_id"],
"blog_url": token["blog_url"],
"scope": token["scope"],
"created_at": token["created_at"]
})
return {
"connected": len(active_sites) > 0,
"sites": active_sites,
"total_sites": len(active_sites)
}
except Exception as e:
logger.error(f"Error getting WordPress connection status: {e}")
return {
"connected": False,
"sites": [],
"total_sites": 0
}

View File

@@ -0,0 +1,287 @@
"""
WordPress Publishing Service
High-level service for publishing content to WordPress sites.
"""
import os
import json
import tempfile
from typing import Optional, Dict, List, Any, Union
from datetime import datetime
from loguru import logger
from .wordpress_service import WordPressService
from .wordpress_content import WordPressContentManager
import sqlite3
class WordPressPublisher:
"""High-level WordPress publishing service."""
def __init__(self, db_path: str = "alwrity.db"):
"""Initialize WordPress publisher."""
self.wp_service = WordPressService(db_path)
self.db_path = db_path
def publish_blog_post(self, user_id: str, site_id: int,
title: str, content: str,
excerpt: str = "",
featured_image_path: Optional[str] = None,
categories: Optional[List[str]] = None,
tags: Optional[List[str]] = None,
status: str = 'draft',
meta_description: str = "") -> Dict[str, Any]:
"""Publish a blog post to WordPress."""
try:
# Get site credentials
credentials = self.wp_service.get_site_credentials(site_id)
if not credentials:
return {
'success': False,
'error': 'WordPress site not found or inactive',
'post_id': None
}
# Initialize content manager
content_manager = WordPressContentManager(
credentials['site_url'],
credentials['username'],
credentials['app_password']
)
# Test connection
if not content_manager._test_connection():
return {
'success': False,
'error': 'Cannot connect to WordPress site',
'post_id': None
}
# Handle featured image
featured_media_id = None
if featured_image_path and os.path.exists(featured_image_path):
try:
# Compress image if it's an image file
if featured_image_path.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')):
compressed_path = content_manager.compress_image(featured_image_path)
featured_media = content_manager.upload_media(
compressed_path,
alt_text=title,
title=title,
caption=excerpt
)
# Clean up temporary file if created
if compressed_path != featured_image_path:
os.unlink(compressed_path)
else:
featured_media = content_manager.upload_media(
featured_image_path,
alt_text=title,
title=title,
caption=excerpt
)
if featured_media:
featured_media_id = featured_media['id']
logger.info(f"Featured image uploaded: {featured_media_id}")
except Exception as e:
logger.warning(f"Failed to upload featured image: {e}")
# Handle categories
category_ids = []
if categories:
for category_name in categories:
category_id = content_manager.get_or_create_category(category_name)
if category_id:
category_ids.append(category_id)
# Handle tags
tag_ids = []
if tags:
for tag_name in tags:
tag_id = content_manager.get_or_create_tag(tag_name)
if tag_id:
tag_ids.append(tag_id)
# Prepare meta data
meta_data = {}
if meta_description:
meta_data['description'] = meta_description
# Create the post
post_data = content_manager.create_post(
title=title,
content=content,
excerpt=excerpt,
featured_media_id=featured_media_id,
categories=category_ids if category_ids else None,
tags=tag_ids if tag_ids else None,
status=status,
meta=meta_data if meta_data else None
)
if post_data:
# Store post reference in database
self._store_post_reference(user_id, site_id, post_data['id'], title, status)
logger.info(f"Blog post published successfully: {title}")
return {
'success': True,
'post_id': post_data['id'],
'post_url': post_data.get('link'),
'featured_media_id': featured_media_id,
'categories': category_ids,
'tags': tag_ids
}
else:
return {
'success': False,
'error': 'Failed to create WordPress post',
'post_id': None
}
except Exception as e:
logger.error(f"Error publishing blog post: {e}")
return {
'success': False,
'error': str(e),
'post_id': None
}
def _store_post_reference(self, user_id: str, site_id: int, wp_post_id: int, title: str, status: str) -> None:
"""Store post reference in database."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO wordpress_posts
(user_id, site_id, wp_post_id, title, status, published_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
''', (user_id, site_id, wp_post_id, title, status,
datetime.now().isoformat() if status == 'publish' else None))
conn.commit()
except Exception as e:
logger.error(f"Error storing post reference: {e}")
def get_user_posts(self, user_id: str, site_id: Optional[int] = None) -> List[Dict[str, Any]]:
"""Get all posts published by user."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
if site_id:
cursor.execute('''
SELECT wp.id, wp.wp_post_id, wp.title, wp.status, wp.published_at, wp.created_at,
ws.site_name, ws.site_url
FROM wordpress_posts wp
JOIN wordpress_sites ws ON wp.site_id = ws.id
WHERE wp.user_id = ? AND wp.site_id = ?
ORDER BY wp.created_at DESC
''', (user_id, site_id))
else:
cursor.execute('''
SELECT wp.id, wp.wp_post_id, wp.title, wp.status, wp.published_at, wp.created_at,
ws.site_name, ws.site_url
FROM wordpress_posts wp
JOIN wordpress_sites ws ON wp.site_id = ws.id
WHERE wp.user_id = ?
ORDER BY wp.created_at DESC
''', (user_id,))
posts = []
for row in cursor.fetchall():
posts.append({
'id': row[0],
'wp_post_id': row[1],
'title': row[2],
'status': row[3],
'published_at': row[4],
'created_at': row[5],
'site_name': row[6],
'site_url': row[7]
})
return posts
except Exception as e:
logger.error(f"Error getting user posts: {e}")
return []
def update_post_status(self, user_id: str, post_id: int, status: str) -> bool:
"""Update post status (draft/publish)."""
try:
# Get post info
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT wp.site_id, wp.wp_post_id, ws.site_url, ws.username, ws.app_password
FROM wordpress_posts wp
JOIN wordpress_sites ws ON wp.site_id = ws.id
WHERE wp.id = ? AND wp.user_id = ?
''', (post_id, user_id))
result = cursor.fetchone()
if not result:
return False
site_id, wp_post_id, site_url, username, app_password = result
# Update in WordPress
content_manager = WordPressContentManager(site_url, username, app_password)
wp_result = content_manager.update_post(wp_post_id, status=status)
if wp_result:
# Update in database
cursor.execute('''
UPDATE wordpress_posts
SET status = ?, published_at = ?
WHERE id = ?
''', (status, datetime.now().isoformat() if status == 'publish' else None, post_id))
conn.commit()
logger.info(f"Post {post_id} status updated to {status}")
return True
return False
except Exception as e:
logger.error(f"Error updating post status: {e}")
return False
def delete_post(self, user_id: str, post_id: int, force: bool = False) -> bool:
"""Delete a WordPress post."""
try:
# Get post info
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT wp.site_id, wp.wp_post_id, ws.site_url, ws.username, ws.app_password
FROM wordpress_posts wp
JOIN wordpress_sites ws ON wp.site_id = ws.id
WHERE wp.id = ? AND wp.user_id = ?
''', (post_id, user_id))
result = cursor.fetchone()
if not result:
return False
site_id, wp_post_id, site_url, username, app_password = result
# Delete from WordPress
content_manager = WordPressContentManager(site_url, username, app_password)
wp_result = content_manager.delete_post(wp_post_id, force=force)
if wp_result:
# Remove from database
cursor.execute('DELETE FROM wordpress_posts WHERE id = ?', (post_id,))
conn.commit()
logger.info(f"Post {post_id} deleted successfully")
return True
return False
except Exception as e:
logger.error(f"Error deleting post: {e}")
return False

View File

@@ -0,0 +1,249 @@
"""
WordPress Service for ALwrity
Handles WordPress site connections, content publishing, and media management.
"""
import os
import json
import sqlite3
import base64
import mimetypes
import tempfile
from typing import Optional, Dict, List, Any, Tuple
from datetime import datetime
import requests
from requests.auth import HTTPBasicAuth
from PIL import Image
from loguru import logger
class WordPressService:
"""Main WordPress service class for managing WordPress integrations."""
def __init__(self, db_path: str = "alwrity.db"):
"""Initialize WordPress service with database path."""
self.db_path = db_path
self.api_version = "v2"
self._ensure_tables()
def _ensure_tables(self) -> None:
"""Ensure required database tables exist."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# WordPress sites table
cursor.execute('''
CREATE TABLE IF NOT EXISTS wordpress_sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
site_url TEXT NOT NULL,
site_name TEXT,
username TEXT NOT NULL,
app_password TEXT NOT NULL,
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, site_url)
)
''')
# WordPress posts table for tracking published content
cursor.execute('''
CREATE TABLE IF NOT EXISTS wordpress_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
site_id INTEGER NOT NULL,
wp_post_id INTEGER NOT NULL,
title TEXT NOT NULL,
status TEXT DEFAULT 'draft',
published_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (site_id) REFERENCES wordpress_sites (id)
)
''')
conn.commit()
logger.info("WordPress database tables ensured")
except Exception as e:
logger.error(f"Error ensuring WordPress tables: {e}")
raise
def add_site(self, user_id: str, site_url: str, site_name: str, username: str, app_password: str) -> bool:
"""Add a new WordPress site connection."""
try:
# Validate site URL format
if not site_url.startswith(('http://', 'https://')):
site_url = f"https://{site_url}"
# Test connection before saving
if not self._test_connection(site_url, username, app_password):
logger.error(f"Failed to connect to WordPress site: {site_url}")
return False
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT OR REPLACE INTO wordpress_sites
(user_id, site_url, site_name, username, app_password, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
''', (user_id, site_url, site_name, username, app_password))
conn.commit()
logger.info(f"WordPress site added for user {user_id}: {site_name}")
return True
except Exception as e:
logger.error(f"Error adding WordPress site: {e}")
return False
def get_user_sites(self, user_id: str) -> List[Dict[str, Any]]:
"""Get all WordPress sites for a user."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, site_url, site_name, username, is_active, created_at, updated_at
FROM wordpress_sites
WHERE user_id = ? AND is_active = 1
ORDER BY updated_at DESC
''', (user_id,))
sites = []
for row in cursor.fetchall():
sites.append({
'id': row[0],
'site_url': row[1],
'site_name': row[2],
'username': row[3],
'is_active': bool(row[4]),
'created_at': row[5],
'updated_at': row[6]
})
logger.info(f"Retrieved {len(sites)} WordPress sites for user {user_id}")
return sites
except Exception as e:
logger.error(f"Error getting WordPress sites for user {user_id}: {e}")
return []
def get_site_credentials(self, site_id: int) -> Optional[Dict[str, str]]:
"""Get credentials for a specific WordPress site."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT site_url, username, app_password
FROM wordpress_sites
WHERE id = ? AND is_active = 1
''', (site_id,))
result = cursor.fetchone()
if result:
return {
'site_url': result[0],
'username': result[1],
'app_password': result[2]
}
return None
except Exception as e:
logger.error(f"Error getting credentials for site {site_id}: {e}")
return None
def _test_connection(self, site_url: str, username: str, app_password: str) -> bool:
"""Test WordPress site connection."""
try:
# Test with a simple API call
api_url = f"{site_url}/wp-json/wp/v2/users/me"
response = requests.get(api_url, auth=HTTPBasicAuth(username, app_password), timeout=10)
if response.status_code == 200:
logger.info(f"WordPress connection test successful for {site_url}")
return True
else:
logger.warning(f"WordPress connection test failed for {site_url}: {response.status_code}")
return False
except Exception as e:
logger.error(f"WordPress connection test error for {site_url}: {e}")
return False
def disconnect_site(self, user_id: str, site_id: int) -> bool:
"""Disconnect a WordPress site."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE wordpress_sites
SET is_active = 0, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
''', (site_id, user_id))
conn.commit()
logger.info(f"WordPress site {site_id} disconnected for user {user_id}")
return True
except Exception as e:
logger.error(f"Error disconnecting WordPress site {site_id}: {e}")
return False
def get_site_info(self, site_id: int) -> Optional[Dict[str, Any]]:
"""Get detailed information about a WordPress site."""
try:
credentials = self.get_site_credentials(site_id)
if not credentials:
return None
site_url = credentials['site_url']
username = credentials['username']
app_password = credentials['app_password']
# Get site information
info = {
'site_url': site_url,
'username': username,
'api_version': self.api_version
}
# Test connection and get basic info
if self._test_connection(site_url, username, app_password):
info['connected'] = True
info['last_checked'] = datetime.now().isoformat()
else:
info['connected'] = False
info['last_checked'] = datetime.now().isoformat()
return info
except Exception as e:
logger.error(f"Error getting site info for {site_id}: {e}")
return None
def get_posts_for_all_sites(self, user_id: str) -> List[Dict[str, Any]]:
"""Get all tracked WordPress posts for all sites of a user."""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT wp.id, wp.wordpress_post_id, wp.title, wp.status, wp.published_at, wp.last_updated_at,
ws.site_name, ws.site_url
FROM wordpress_posts wp
JOIN wordpress_sites ws ON wp.site_id = ws.id
WHERE wp.user_id = ? AND ws.is_active = TRUE
ORDER BY wp.published_at DESC
''', (user_id,))
posts = []
for post_data in cursor.fetchall():
posts.append({
"id": post_data[0],
"wp_post_id": post_data[1],
"title": post_data[2],
"status": post_data[3],
"published_at": post_data[4],
"created_at": post_data[5],
"site_name": post_data[6],
"site_url": post_data[7]
})
return posts