Base code
This commit is contained in:
529
backend/services/wix_service.py
Normal file
529
backend/services/wix_service.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
Wix Integration Service
|
||||
|
||||
Handles authentication, permission checking, and blog publishing to Wix websites.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import uuid
|
||||
from typing import Dict, Any, Optional, List
|
||||
from loguru import logger
|
||||
from datetime import datetime, timedelta
|
||||
import base64
|
||||
from urllib.parse import urlencode, parse_qs
|
||||
import jwt
|
||||
import base64 as b64
|
||||
from services.integrations.wix.blog import WixBlogService
|
||||
from services.integrations.wix.media import WixMediaService
|
||||
from services.integrations.wix.utils import extract_meta_from_token, normalize_token_string, extract_member_id_from_access_token as utils_extract_member
|
||||
from services.integrations.wix.content import convert_content_to_ricos as ricos_builder
|
||||
from services.integrations.wix.auth import WixAuthService
|
||||
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 as publish_blog_post
|
||||
|
||||
class WixService:
|
||||
"""Service for interacting with Wix APIs"""
|
||||
|
||||
def __init__(self):
|
||||
self.client_id = os.getenv('WIX_CLIENT_ID')
|
||||
self.redirect_uri = os.getenv('WIX_REDIRECT_URI', 'https://alwrity-ai.vercel.app/wix/callback')
|
||||
self.base_url = 'https://www.wixapis.com'
|
||||
self.oauth_url = 'https://www.wix.com/oauth/authorize'
|
||||
# Modular services
|
||||
self.blog_service = WixBlogService(self.base_url, self.client_id)
|
||||
self.media_service = WixMediaService(self.base_url)
|
||||
self.auth_service = WixAuthService(self.client_id, self.redirect_uri, self.base_url)
|
||||
|
||||
if not self.client_id:
|
||||
logger.warning("Wix client ID not configured. Set WIX_CLIENT_ID environment variable.")
|
||||
|
||||
def get_authorization_url(self, state: str = None) -> str:
|
||||
"""
|
||||
Generate Wix OAuth authorization URL for "on behalf of user" authentication
|
||||
|
||||
This implements the "Authenticate on behalf of a Wix User" flow as described in:
|
||||
https://dev.wix.com/docs/build-apps/develop-your-app/access/authentication/authenticate-on-behalf-of-a-wix-user
|
||||
|
||||
Args:
|
||||
state: Optional state parameter for security
|
||||
|
||||
Returns:
|
||||
Authorization URL for user to visit
|
||||
"""
|
||||
url, code_verifier = self.auth_service.generate_authorization_url(state)
|
||||
self._code_verifier = code_verifier
|
||||
return url
|
||||
|
||||
def _create_redirect_session_for_auth(self, redirect_uri: str, client_id: str, code_challenge: str, state: str) -> str:
|
||||
"""
|
||||
Create a redirect session for Wix Headless OAuth authentication using Redirects API
|
||||
|
||||
Args:
|
||||
redirect_uri: The redirect URI for OAuth callback
|
||||
client_id: The OAuth client ID
|
||||
code_challenge: The PKCE code challenge
|
||||
state: The OAuth state parameter
|
||||
|
||||
Returns:
|
||||
The redirect URL for OAuth authentication
|
||||
"""
|
||||
try:
|
||||
# According to Wix documentation, we need to use the Redirects API
|
||||
# to create a redirect session for OAuth authentication
|
||||
# This is the correct approach for Wix Headless OAuth
|
||||
|
||||
# For now, return the direct OAuth URL as a fallback
|
||||
# In production, this should call the Wix Redirects API
|
||||
scope = (
|
||||
"BLOG.CREATE-DRAFT,BLOG.PUBLISH-POST,BLOG.READ-CATEGORY," \
|
||||
"BLOG.CREATE-CATEGORY,BLOG.READ-TAG,BLOG.CREATE-TAG," \
|
||||
"MEDIA.SITE_MEDIA_FILES_IMPORT"
|
||||
)
|
||||
redirect_url = (
|
||||
"https://www.wix.com/oauth/authorize?client_id="
|
||||
f"{client_id}&redirect_uri={redirect_uri}&response_type=code"
|
||||
f"&scope={scope}&code_challenge={code_challenge}"
|
||||
f"&code_challenge_method=S256&state={state}"
|
||||
)
|
||||
|
||||
logger.info(f"Generated Wix Headless OAuth redirect URL: {redirect_url}")
|
||||
logger.warning("Using direct OAuth URL - should implement Redirects API for production")
|
||||
return redirect_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create redirect session for auth: {e}")
|
||||
raise
|
||||
|
||||
def exchange_code_for_tokens(self, code: str, code_verifier: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Exchange authorization code for access and refresh tokens using PKCE
|
||||
|
||||
Args:
|
||||
code: Authorization code from Wix
|
||||
code_verifier: PKCE code verifier (uses stored one if not provided)
|
||||
|
||||
Returns:
|
||||
Token response with access_token, refresh_token, etc.
|
||||
"""
|
||||
if not self.client_id:
|
||||
raise ValueError("Wix client ID not configured")
|
||||
if not code_verifier:
|
||||
code_verifier = getattr(self, '_code_verifier', None)
|
||||
if not code_verifier:
|
||||
raise ValueError("Code verifier not found. Please provide code_verifier parameter.")
|
||||
try:
|
||||
return self.auth_service.exchange_code_for_tokens(code, code_verifier)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to exchange code for tokens: {e}")
|
||||
raise
|
||||
|
||||
def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Refresh access token using refresh token (Wix Headless OAuth)
|
||||
|
||||
Args:
|
||||
refresh_token: Valid refresh token
|
||||
|
||||
Returns:
|
||||
New token response
|
||||
"""
|
||||
if not self.client_id:
|
||||
raise ValueError("Wix client ID not configured")
|
||||
try:
|
||||
return self.auth_service.refresh_access_token(refresh_token)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to refresh access token: {e}")
|
||||
raise
|
||||
|
||||
def get_site_info(self, access_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about the connected Wix site
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
|
||||
Returns:
|
||||
Site information
|
||||
"""
|
||||
token_str = normalize_token_string(access_token)
|
||||
if not token_str:
|
||||
raise ValueError("Invalid access token format for create_blog_post")
|
||||
try:
|
||||
return self.auth_service.get_site_info(token_str)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to get site info: {e}")
|
||||
raise
|
||||
|
||||
def get_current_member(self, access_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current member information (for third-party apps)
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
|
||||
Returns:
|
||||
Current member information
|
||||
"""
|
||||
token_str = normalize_token_string(access_token)
|
||||
if not token_str:
|
||||
raise ValueError("Invalid access token format for get_current_member")
|
||||
try:
|
||||
return self.auth_service.get_current_member(token_str, self.client_id)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to get current member: {e}")
|
||||
raise
|
||||
|
||||
def extract_member_id_from_access_token(self, access_token: Any) -> Optional[str]:
|
||||
return utils_extract_member(access_token)
|
||||
|
||||
def _normalize_token_string(self, access_token: Any) -> Optional[str]:
|
||||
return normalize_token_string(access_token)
|
||||
|
||||
def check_blog_permissions(self, access_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if the app has required blog permissions
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
|
||||
Returns:
|
||||
Permission status
|
||||
"""
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
'wix-client-id': self.client_id or ''
|
||||
}
|
||||
|
||||
try:
|
||||
# Try to list blog categories to check permissions
|
||||
response = requests.get(
|
||||
f"{self.base_url}/blog/v1/categories",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {
|
||||
'has_permissions': True,
|
||||
'can_create_posts': True,
|
||||
'can_publish': True
|
||||
}
|
||||
elif response.status_code == 403:
|
||||
return {
|
||||
'has_permissions': False,
|
||||
'can_create_posts': False,
|
||||
'can_publish': False,
|
||||
'error': 'Insufficient permissions'
|
||||
}
|
||||
else:
|
||||
response.raise_for_status()
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to check blog permissions: {e}")
|
||||
return {
|
||||
'has_permissions': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def import_image_to_wix(self, access_token: str, image_url: str, display_name: str = None) -> str:
|
||||
"""
|
||||
Import external image to Wix Media Manager
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
image_url: URL of the image to import
|
||||
display_name: Optional display name for the image
|
||||
|
||||
Returns:
|
||||
Wix media ID
|
||||
"""
|
||||
try:
|
||||
result = self.media_service.import_image(
|
||||
access_token,
|
||||
image_url,
|
||||
display_name or f'Imported Image {datetime.now().strftime("%Y%m%d_%H%M%S")}'
|
||||
)
|
||||
return result['file']['id']
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to import image to Wix: {e}")
|
||||
raise
|
||||
|
||||
def convert_content_to_ricos(self, content: str, images: List[str] = None,
|
||||
use_wix_api: bool = False, access_token: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert markdown content to Ricos JSON format.
|
||||
|
||||
Args:
|
||||
content: Markdown content to convert
|
||||
images: Optional list of image URLs
|
||||
use_wix_api: If True, use Wix's official Ricos Documents API (requires access_token)
|
||||
access_token: Wix access token (required if use_wix_api=True)
|
||||
|
||||
Returns:
|
||||
Ricos JSON document
|
||||
"""
|
||||
if use_wix_api and access_token:
|
||||
try:
|
||||
return convert_via_wix_api(content, access_token, self.base_url)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to convert via Wix API, falling back to custom parser: {e}")
|
||||
# Fall back to custom parser
|
||||
|
||||
# Use custom parser (current implementation)
|
||||
return ricos_builder(content, images)
|
||||
|
||||
|
||||
def create_blog_post(self, access_token: str, title: str, content: str,
|
||||
cover_image_url: str = None, category_ids: List[str] = None,
|
||||
tag_ids: List[str] = None, publish: bool = True,
|
||||
member_id: str = None, seo_metadata: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Create and optionally publish a blog post on Wix
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
title: Blog post title
|
||||
content: Blog post content
|
||||
cover_image_url: Optional cover image URL
|
||||
category_ids: Optional list of category IDs
|
||||
tag_ids: Optional list of tag IDs
|
||||
publish: Whether to publish immediately or save as draft
|
||||
member_id: Required for third-party apps - the member ID of the post author
|
||||
seo_metadata: Optional 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
|
||||
- canonical_url: Canonical URL
|
||||
|
||||
Returns:
|
||||
Created blog post information
|
||||
"""
|
||||
# Normalize access token to string to avoid type issues (can be dict/int from storage)
|
||||
from services.integrations.wix.utils import normalize_token_string
|
||||
normalized_token = normalize_token_string(access_token)
|
||||
if normalized_token:
|
||||
token_to_use = normalized_token.strip()
|
||||
else:
|
||||
token_to_use = str(access_token).strip() if access_token is not None else ""
|
||||
|
||||
if not token_to_use:
|
||||
raise ValueError("access_token is required to create a blog post")
|
||||
|
||||
return publish_blog_post(
|
||||
blog_service=self.blog_service,
|
||||
access_token=token_to_use,
|
||||
title=title,
|
||||
content=content,
|
||||
member_id=member_id,
|
||||
cover_image_url=cover_image_url,
|
||||
category_ids=category_ids,
|
||||
tag_ids=tag_ids,
|
||||
publish=publish,
|
||||
seo_metadata=seo_metadata,
|
||||
import_image_func=self.import_image_to_wix,
|
||||
lookup_categories_func=self.lookup_or_create_categories,
|
||||
lookup_tags_func=self.lookup_or_create_tags,
|
||||
base_url=self.base_url
|
||||
)
|
||||
|
||||
def get_blog_categories(self, access_token: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get available blog categories
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
|
||||
Returns:
|
||||
List of blog categories
|
||||
"""
|
||||
try:
|
||||
return self.blog_service.list_categories(access_token)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to get blog categories: {e}")
|
||||
raise
|
||||
|
||||
def get_blog_tags(self, access_token: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get available blog tags
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
|
||||
Returns:
|
||||
List of blog tags
|
||||
"""
|
||||
try:
|
||||
return self.blog_service.list_tags(access_token)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to get blog tags: {e}")
|
||||
raise
|
||||
|
||||
def lookup_or_create_categories(self, access_token: str, category_names: List[str],
|
||||
extra_headers: Optional[Dict[str, str]] = None) -> List[str]:
|
||||
"""
|
||||
Lookup existing categories by name or create new ones, return their IDs.
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
category_names: List of category name strings
|
||||
extra_headers: Optional extra headers (e.g., wix-site-id)
|
||||
|
||||
Returns:
|
||||
List of category UUIDs
|
||||
"""
|
||||
if not category_names:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Get existing categories
|
||||
existing_categories = self.blog_service.list_categories(access_token, extra_headers)
|
||||
|
||||
# Create name -> ID mapping (case-insensitive)
|
||||
category_map = {}
|
||||
for cat in existing_categories:
|
||||
cat_label = cat.get('label', '').strip()
|
||||
cat_id = cat.get('id')
|
||||
if cat_label and cat_id:
|
||||
category_map[cat_label.lower()] = cat_id
|
||||
|
||||
category_ids = []
|
||||
for category_name in category_names:
|
||||
category_name_clean = str(category_name).strip()
|
||||
if not category_name_clean:
|
||||
continue
|
||||
|
||||
# Lookup existing category (case-insensitive)
|
||||
category_id = category_map.get(category_name_clean.lower())
|
||||
|
||||
if not category_id:
|
||||
# Create new category
|
||||
try:
|
||||
logger.info(f"Creating new category: {category_name_clean}")
|
||||
result = self.blog_service.create_category(
|
||||
access_token,
|
||||
label=category_name_clean,
|
||||
extra_headers=extra_headers
|
||||
)
|
||||
new_category = result.get('category', {})
|
||||
category_id = new_category.get('id')
|
||||
if category_id:
|
||||
category_ids.append(category_id)
|
||||
# Update map to avoid duplicate creates
|
||||
category_map[category_name_clean.lower()] = category_id
|
||||
logger.info(f"Created category '{category_name_clean}' with ID: {category_id}")
|
||||
except Exception as create_error:
|
||||
logger.warning(f"Failed to create category '{category_name_clean}': {create_error}")
|
||||
# Continue with other categories
|
||||
else:
|
||||
category_ids.append(category_id)
|
||||
logger.info(f"Found existing category '{category_name_clean}' with ID: {category_id}")
|
||||
|
||||
return category_ids
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to lookup/create categories: {e}")
|
||||
return []
|
||||
|
||||
def lookup_or_create_tags(self, access_token: str, tag_names: List[str],
|
||||
extra_headers: Optional[Dict[str, str]] = None) -> List[str]:
|
||||
"""
|
||||
Lookup existing tags by name or create new ones, return their IDs.
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
tag_names: List of tag name strings
|
||||
extra_headers: Optional extra headers (e.g., wix-site-id)
|
||||
|
||||
Returns:
|
||||
List of tag UUIDs
|
||||
"""
|
||||
if not tag_names:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Get existing tags
|
||||
existing_tags = self.blog_service.list_tags(access_token, extra_headers)
|
||||
|
||||
# Create name -> ID mapping (case-insensitive)
|
||||
tag_map = {}
|
||||
for tag in existing_tags:
|
||||
tag_label = tag.get('label', '').strip()
|
||||
tag_id = tag.get('id')
|
||||
if tag_label and tag_id:
|
||||
tag_map[tag_label.lower()] = tag_id
|
||||
|
||||
tag_ids = []
|
||||
for tag_name in tag_names:
|
||||
tag_name_clean = str(tag_name).strip()
|
||||
if not tag_name_clean:
|
||||
continue
|
||||
|
||||
# Lookup existing tag (case-insensitive)
|
||||
tag_id = tag_map.get(tag_name_clean.lower())
|
||||
|
||||
if not tag_id:
|
||||
# Create new tag
|
||||
try:
|
||||
logger.info(f"Creating new tag: {tag_name_clean}")
|
||||
result = self.blog_service.create_tag(
|
||||
access_token,
|
||||
label=tag_name_clean,
|
||||
extra_headers=extra_headers
|
||||
)
|
||||
new_tag = result.get('tag', {})
|
||||
tag_id = new_tag.get('id')
|
||||
if tag_id:
|
||||
tag_ids.append(tag_id)
|
||||
# Update map to avoid duplicate creates
|
||||
tag_map[tag_name_clean.lower()] = tag_id
|
||||
logger.info(f"Created tag '{tag_name_clean}' with ID: {tag_id}")
|
||||
except Exception as create_error:
|
||||
logger.warning(f"Failed to create tag '{tag_name_clean}': {create_error}")
|
||||
# Continue with other tags
|
||||
else:
|
||||
tag_ids.append(tag_id)
|
||||
logger.info(f"Found existing tag '{tag_name_clean}' with ID: {tag_id}")
|
||||
|
||||
return tag_ids
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to lookup/create tags: {e}")
|
||||
return []
|
||||
|
||||
def publish_draft_post(self, access_token: str, draft_post_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Publish a draft post by ID.
|
||||
"""
|
||||
try:
|
||||
result = self.blog_service.publish_draft(access_token, draft_post_id)
|
||||
logger.info(f"DEBUG: Publish result: {result}")
|
||||
return result
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to publish draft post: {e}")
|
||||
raise
|
||||
|
||||
def create_category(self, access_token: str, label: str, description: Optional[str] = None,
|
||||
language: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a blog category.
|
||||
"""
|
||||
try:
|
||||
return self.blog_service.create_category(access_token, label, description, language)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to create category: {e}")
|
||||
raise
|
||||
|
||||
def create_tag(self, access_token: str, label: str, language: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a blog tag.
|
||||
"""
|
||||
try:
|
||||
return self.blog_service.create_tag(access_token, label, language)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to create tag: {e}")
|
||||
raise
|
||||
Reference in New Issue
Block a user