Base code
This commit is contained in:
0
backend/services/integrations/README
Normal file
0
backend/services/integrations/README
Normal file
170
backend/services/integrations/README.md
Normal file
170
backend/services/integrations/README.md
Normal 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
|
||||
13
backend/services/integrations/__init__.py
Normal file
13
backend/services/integrations/__init__.py
Normal 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'
|
||||
]
|
||||
925
backend/services/integrations/bing_oauth.py
Normal file
925
backend/services/integrations/bing_oauth.py
Normal 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)}
|
||||
15
backend/services/integrations/wix/__init__.py
Normal file
15
backend/services/integrations/wix/__init__.py
Normal 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',
|
||||
]
|
||||
|
||||
86
backend/services/integrations/wix/auth.py
Normal file
86
backend/services/integrations/wix/auth.py
Normal 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()
|
||||
|
||||
|
||||
132
backend/services/integrations/wix/auth_utils.py
Normal file
132
backend/services/integrations/wix/auth_utils.py
Normal 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
|
||||
|
||||
121
backend/services/integrations/wix/blog.py
Normal file
121
backend/services/integrations/wix/blog.py
Normal 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()
|
||||
|
||||
|
||||
743
backend/services/integrations/wix/blog_publisher.py
Normal file
743
backend/services/integrations/wix/blog_publisher.py
Normal 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
|
||||
|
||||
475
backend/services/integrations/wix/content.py
Normal file
475
backend/services/integrations/wix/content.py
Normal 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
|
||||
}
|
||||
|
||||
|
||||
118
backend/services/integrations/wix/logger.py
Normal file
118
backend/services/integrations/wix/logger.py
Normal 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()
|
||||
|
||||
31
backend/services/integrations/wix/media.py
Normal file
31
backend/services/integrations/wix/media.py
Normal 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()
|
||||
|
||||
|
||||
302
backend/services/integrations/wix/ricos_converter.py
Normal file
302
backend/services/integrations/wix/ricos_converter.py
Normal 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
|
||||
|
||||
311
backend/services/integrations/wix/seo.py
Normal file
311
backend/services/integrations/wix/seo.py
Normal 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
|
||||
|
||||
109
backend/services/integrations/wix/utils.py
Normal file
109
backend/services/integrations/wix/utils.py
Normal 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}
|
||||
|
||||
|
||||
265
backend/services/integrations/wix_oauth.py
Normal file
265
backend/services/integrations/wix_oauth.py
Normal 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
|
||||
|
||||
320
backend/services/integrations/wordpress_content.py
Normal file
320
backend/services/integrations/wordpress_content.py
Normal 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
|
||||
375
backend/services/integrations/wordpress_oauth.py
Normal file
375
backend/services/integrations/wordpress_oauth.py
Normal 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
|
||||
}
|
||||
287
backend/services/integrations/wordpress_publisher.py
Normal file
287
backend/services/integrations/wordpress_publisher.py
Normal 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
|
||||
249
backend/services/integrations/wordpress_service.py
Normal file
249
backend/services/integrations/wordpress_service.py
Normal 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
|
||||
Reference in New Issue
Block a user