story writer backend migration complete, Blog writer SEO and story writer backend migration complete, Blog writer SEO and story writer frontend migration complete
This commit is contained in:
@@ -25,7 +25,11 @@ class WixAuthService:
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE',
|
||||
'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'
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -10,9 +10,39 @@ class WixBlogService:
|
||||
|
||||
def headers(self, access_token: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||
h: Dict[str, str] = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'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:
|
||||
@@ -20,41 +50,38 @@ class WixBlogService:
|
||||
return h
|
||||
|
||||
def create_draft_post(self, access_token: str, payload: Dict[str, Any], extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
# Log the exact payload being sent for debugging
|
||||
"""Create draft post with consolidated logging"""
|
||||
from .logger import wix_logger
|
||||
import json
|
||||
logger.warning(f"📤 Sending to Wix Blog API:")
|
||||
logger.warning(f" Endpoint: {self.base_url}/blog/v3/draft-posts")
|
||||
logger.warning(f" Payload top-level keys: {list(payload.keys())}")
|
||||
|
||||
# Build payload summary for logging
|
||||
payload_summary = {}
|
||||
if 'draftPost' in payload:
|
||||
dp = payload['draftPost']
|
||||
logger.warning(f" draftPost keys: {list(dp.keys())}")
|
||||
if 'richContent' in dp:
|
||||
rc = dp['richContent']
|
||||
logger.warning(f" richContent keys: {list(rc.keys()) if isinstance(rc, dict) else 'N/A'}")
|
||||
if isinstance(rc, dict) and 'nodes' in rc:
|
||||
nodes = rc['nodes']
|
||||
logger.warning(f" richContent.nodes count: {len(nodes) if isinstance(nodes, list) else 'N/A'}")
|
||||
# Inspect first LIST_ITEM node if any
|
||||
for i, node in enumerate(nodes[:10]):
|
||||
if isinstance(node, dict) and node.get('type') == 'LIST_ITEM':
|
||||
logger.warning(f" Found LIST_ITEM at index {i}:")
|
||||
logger.warning(f" Keys: {list(node.keys())}")
|
||||
logger.warning(f" Has listItemData: {'listItemData' in node}")
|
||||
if 'listItemData' in node:
|
||||
logger.warning(f" listItemData type: {type(node['listItemData'])}, value: {node['listItemData']}")
|
||||
if 'nodes' in node:
|
||||
nested = node['nodes']
|
||||
logger.warning(f" Nested nodes count: {len(nested) if isinstance(nested, list) else 'N/A'}")
|
||||
for j, n_node in enumerate(nested[:3]):
|
||||
if isinstance(n_node, dict):
|
||||
logger.warning(f" Nested node {j}: type={n_node.get('type')}, keys={list(n_node.keys())}")
|
||||
if n_node.get('type') == 'PARAGRAPH' and 'paragraphData' in n_node:
|
||||
logger.warning(f" paragraphData type: {type(n_node['paragraphData'])}, value: {n_node['paragraphData']}")
|
||||
break # Only inspect first LIST_ITEM
|
||||
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
|
||||
}
|
||||
|
||||
logger.warning(f" Full Payload JSON (first 8000 chars):\n{json.dumps(payload, indent=2, ensure_ascii=False)[:8000]}...")
|
||||
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 = requests.post(f"{self.base_url}/blog/v3/draft-posts", headers=self.headers(access_token, extra_headers), json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ 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]:
|
||||
@@ -220,10 +222,96 @@ def create_blog_post(
|
||||
if not member_id:
|
||||
raise ValueError("memberId is required for third-party apps creating blog posts")
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
# 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
|
||||
@@ -231,20 +319,87 @@ def create_blog_post(
|
||||
content = "This is a post from ALwrity."
|
||||
logger.warning("⚠️ Content was empty, using default text")
|
||||
|
||||
# Try Wix API first (more reliable), fall back to custom parser
|
||||
ricos_content = None
|
||||
# Quick token/permission check (only log if issues found)
|
||||
has_blog_scope = None
|
||||
meta_site_id = None
|
||||
try:
|
||||
logger.warning("🔄 Attempting to convert markdown to Ricos via Wix API...")
|
||||
ricos_content = convert_via_wix_api(content, access_token, base_url)
|
||||
logger.warning(f"✅ Wix API conversion successful. Ricos document has {len(ricos_content.get('nodes', []))} nodes")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Wix Ricos API conversion failed: {e}. Falling back to custom parser...")
|
||||
# Fall back to custom parser
|
||||
ricos_content = convert_content_to_ricos(content, None)
|
||||
logger.warning(f"✅ Custom parser conversion complete. Ricos document has {len(ricos_content.get('nodes', []))} nodes")
|
||||
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
|
||||
|
||||
# Validate Ricos content
|
||||
ricos_content = validate_ricos_content(ricos_content)
|
||||
# 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)
|
||||
@@ -252,7 +407,7 @@ def create_blog_post(
|
||||
'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 document object
|
||||
'richContent': ricos_content, # Must be a valid Ricos object with ONLY 'nodes'
|
||||
},
|
||||
'publish': bool(publish),
|
||||
'fieldsets': ['URL'] # Simplified fieldsets
|
||||
@@ -340,76 +495,34 @@ def create_blog_post(
|
||||
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:
|
||||
logger.warning(f"📊 Building SEO data from metadata. Keys: {list(seo_metadata.keys())}")
|
||||
seo_data = build_seo_data(seo_metadata, title)
|
||||
if seo_data:
|
||||
# Log detailed SEO structure
|
||||
logger.warning(f"📋 SEO data built: {len(seo_data.get('tags', []))} tags, {len(seo_data.get('settings', {}).get('keywords', []))} keywords")
|
||||
|
||||
# Log each SEO tag for debugging (key ones only to avoid too much output)
|
||||
if seo_data.get('tags'):
|
||||
for idx, tag in enumerate(seo_data['tags'][:3]): # First 3 tags only
|
||||
tag_type = tag.get('type')
|
||||
if tag_type == 'title':
|
||||
logger.warning(f" SEO tag {idx+1}: type={tag_type}, children={str(tag.get('children', ''))[:50]}...")
|
||||
else:
|
||||
props = tag.get('props', {})
|
||||
content_preview = str(props.get('content', props.get('href', props.get('name', ''))))[:50]
|
||||
logger.warning(f" SEO tag {idx+1}: type={tag_type}, props={list(props.keys())}, content={content_preview}...")
|
||||
if len(seo_data['tags']) > 3:
|
||||
logger.warning(f" ... and {len(seo_data['tags']) - 3} more SEO tags")
|
||||
|
||||
blog_data['draftPost']['seoData'] = seo_data
|
||||
logger.warning(f"✅ Added seoData to blog post with {len(seo_data.get('tags', []))} tags")
|
||||
else:
|
||||
logger.warning("⚠️ SEO data was empty after building - check build_seo_data function")
|
||||
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 (separate field from seoData)
|
||||
# Add SEO slug if provided
|
||||
if seo_metadata.get('url_slug'):
|
||||
blog_data['draftPost']['seoSlug'] = str(seo_metadata.get('url_slug')).strip()
|
||||
logger.warning(f"✅ Added SEO slug: {blog_data['draftPost']['seoSlug']}")
|
||||
else:
|
||||
logger.warning("⚠️ No SEO metadata provided to create_blog_post")
|
||||
|
||||
# Log the payload structure for debugging (without sensitive data)
|
||||
logger.warning(f"📝 Creating blog post with title: '{title}'")
|
||||
logger.warning(f"📋 Draft post fields: {list(blog_data['draftPost'].keys())}")
|
||||
|
||||
# Detailed SEO logging
|
||||
if 'seoData' in blog_data['draftPost']:
|
||||
seo_data_debug = blog_data['draftPost']['seoData']
|
||||
logger.warning(f"📊 SEO data in payload: {len(seo_data_debug.get('tags', []))} tags, {len(seo_data_debug.get('settings', {}).get('keywords', []))} keywords")
|
||||
|
||||
# Log sample SEO tags (first 2 only to avoid too much output)
|
||||
if seo_data_debug.get('tags'):
|
||||
logger.warning("📋 SEO Tags sample:")
|
||||
for i, tag in enumerate(seo_data_debug['tags'][:2]): # First 2 tags
|
||||
logger.warning(f" Tag {i+1}: type={tag.get('type')}, custom={tag.get('custom')}, disabled={tag.get('disabled')}")
|
||||
if len(seo_data_debug['tags']) > 2:
|
||||
logger.warning(f" ... and {len(seo_data_debug['tags']) - 2} more tags")
|
||||
|
||||
if seo_data_debug.get('settings', {}).get('keywords'):
|
||||
keywords_list = [k.get('term') for k in seo_data_debug['settings']['keywords'][:3]]
|
||||
logger.warning(f"🔑 Keywords: {keywords_list}")
|
||||
|
||||
# Log FULL seoData structure for debugging
|
||||
import json
|
||||
try:
|
||||
seo_json = json.dumps(seo_data_debug, indent=2, ensure_ascii=False)
|
||||
logger.warning(f"📄 FULL seoData JSON:\n{seo_json[:2000]}...") # First 2000 chars
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to serialize seoData: {e}")
|
||||
else:
|
||||
logger.warning("⚠️ No seoData in draft post payload!")
|
||||
|
||||
try:
|
||||
# Add wix-site-id header if we can extract it from token
|
||||
# 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', {})
|
||||
@@ -423,12 +536,8 @@ def create_blog_post(
|
||||
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 as e:
|
||||
logger.debug(f"Could not extract site ID from token: {e}")
|
||||
|
||||
# Make the API call
|
||||
logger.warning(f"🚀 Calling Wix API: POST /blog/v3/draft-posts")
|
||||
logger.warning(f"📦 Payload: title='{blog_data['draftPost'].get('title')}', has_seoData={'seoData' in blog_data['draftPost']}, has_richContent={'richContent' in blog_data['draftPost']}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Validate payload structure before sending
|
||||
draft_post = blog_data.get('draftPost', {})
|
||||
@@ -617,88 +726,13 @@ def create_blog_post(
|
||||
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'))}")
|
||||
|
||||
# Try sending WITHOUT SEO data first to isolate the issue
|
||||
test_without_seo = False # Disabled - listItemData issue fixed
|
||||
if test_without_seo and 'seoData' in blog_data['draftPost']:
|
||||
logger.warning("🧪 TESTING WITHOUT SEO DATA to isolate issue...")
|
||||
# Clone the payload without SEO data
|
||||
test_payload_no_seo = {
|
||||
'draftPost': {
|
||||
'title': blog_data['draftPost']['title'],
|
||||
'memberId': blog_data['draftPost']['memberId'],
|
||||
'richContent': blog_data['draftPost']['richContent'],
|
||||
'excerpt': blog_data['draftPost'].get('excerpt', '')
|
||||
},
|
||||
'publish': False,
|
||||
'fieldsets': ['URL']
|
||||
}
|
||||
try:
|
||||
logger.warning("🧪 Attempting without SEO data...")
|
||||
test_result = blog_service.create_draft_post(access_token, test_payload_no_seo, extra_headers or None)
|
||||
logger.warning(f"✅ WITHOUT SEO DATA SUCCEEDED! Post ID: {test_result.get('draftPost', {}).get('id')}")
|
||||
logger.error("⚠️⚠️⚠️ ISSUE IS WITH SEO DATA STRUCTURE!")
|
||||
# If this succeeds, don't send the full payload, just return this result
|
||||
return test_result
|
||||
except Exception as e:
|
||||
logger.warning(f"❌ WITHOUT SEO DATA ALSO FAILED: {e}")
|
||||
logger.warning("⚠️ Issue is NOT with SEO data, continuing with full payload...")
|
||||
|
||||
# Try sending with minimal structure first to isolate the issue
|
||||
# Create a test payload with just required fields
|
||||
minimal_test = False # Set to True to test with minimal payload
|
||||
if minimal_test:
|
||||
logger.warning("🧪 TESTING WITH MINIMAL PAYLOAD (title + memberId + simple richContent)")
|
||||
test_payload = {
|
||||
'draftPost': {
|
||||
'title': blog_data['draftPost']['title'],
|
||||
'memberId': blog_data['draftPost']['memberId'],
|
||||
'richContent': {
|
||||
'nodes': [
|
||||
{
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': [
|
||||
{
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'textData': {
|
||||
'text': 'Test paragraph',
|
||||
'decorations': []
|
||||
}
|
||||
}
|
||||
],
|
||||
'paragraphData': {}
|
||||
}
|
||||
],
|
||||
'metadata': {'version': 1, 'id': str(uuid.uuid4())},
|
||||
'documentStyle': {}
|
||||
}
|
||||
},
|
||||
'publish': False,
|
||||
'fieldsets': ['URL']
|
||||
}
|
||||
logger.warning("🧪 Attempting minimal payload first...")
|
||||
try:
|
||||
test_result = blog_service.create_draft_post(access_token, test_payload, extra_headers or None)
|
||||
logger.warning(f"✅ MINIMAL PAYLOAD SUCCEEDED! Post ID: {test_result.get('draftPost', {}).get('id')}")
|
||||
logger.warning("⚠️ Issue is with complex content, not basic structure")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ MINIMAL PAYLOAD ALSO FAILED: {e}")
|
||||
logger.error("⚠️ Issue is with basic structure or permissions")
|
||||
|
||||
result = blog_service.create_draft_post(access_token, blog_data, extra_headers or None)
|
||||
|
||||
# Log response
|
||||
# Log success
|
||||
draft_post = result.get('draftPost', {})
|
||||
logger.warning(f"✅ Blog post created successfully! Post ID: {draft_post.get('id', 'N/A')}")
|
||||
|
||||
# Check if SEO data was preserved in response
|
||||
if 'seoData' in draft_post:
|
||||
seo_response = draft_post['seoData']
|
||||
logger.warning(f"✅ SEO data confirmed in response: {len(seo_response.get('tags', []))} tags, {len(seo_response.get('settings', {}).get('keywords', []))} keywords")
|
||||
else:
|
||||
logger.warning("⚠️ No seoData in response - it may have been filtered out by Wix API")
|
||||
logger.warning(f"📋 Response fields: {list(draft_post.keys())}")
|
||||
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:
|
||||
|
||||
@@ -13,6 +13,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
return [{
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {'text': '', 'decorations': []}
|
||||
}]
|
||||
|
||||
@@ -32,6 +33,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
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()
|
||||
@@ -46,11 +48,14 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
# 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()
|
||||
if 'BOLD' not in node_decorations:
|
||||
node_decorations.append('BOLD')
|
||||
# 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
|
||||
@@ -63,6 +68,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
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()
|
||||
@@ -79,24 +85,23 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
url_end = text.find(')', url_start)
|
||||
if url_end != -1:
|
||||
url = text[url_start:url_end]
|
||||
# Create link node
|
||||
link_node_id = str(uuid.uuid4())
|
||||
text_node_id = str(uuid.uuid4())
|
||||
link_text_nodes = parse_markdown_inline(link_text)
|
||||
# Wrap link text in LINK node
|
||||
# Per Wix API: Links are decorations on TEXT nodes, not separate node types
|
||||
# Create TEXT node with LINK decoration
|
||||
nodes.append({
|
||||
'id': link_node_id,
|
||||
'type': 'LINK',
|
||||
'nodes': link_text_nodes if link_text_nodes else [{
|
||||
'id': text_node_id,
|
||||
'type': 'TEXT',
|
||||
'textData': {'text': link_text, 'decorations': []}
|
||||
}],
|
||||
'linkData': {
|
||||
'link': {
|
||||
'url': url,
|
||||
'target': '_blank'
|
||||
}
|
||||
'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
|
||||
@@ -109,6 +114,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
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()
|
||||
@@ -121,12 +127,16 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
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']
|
||||
'decorations': [] # CODE is not a valid decoration in Wix API
|
||||
}
|
||||
})
|
||||
i = code_end + 1
|
||||
@@ -139,6 +149,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
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()
|
||||
@@ -155,11 +166,14 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
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()
|
||||
if 'ITALIC' not in node_decorations:
|
||||
node_decorations.append('ITALIC')
|
||||
# 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
|
||||
@@ -174,6 +188,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
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()
|
||||
@@ -185,6 +200,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': text,
|
||||
'decorations': []
|
||||
@@ -439,6 +455,7 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
'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': []
|
||||
@@ -448,14 +465,11 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
}
|
||||
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 {
|
||||
'type': 'DOCUMENT',
|
||||
'id': str(uuid.uuid4()),
|
||||
'nodes': nodes,
|
||||
'metadata': {'version': 1, 'id': str(uuid.uuid4())},
|
||||
'documentStyle': {
|
||||
'paragraph': {'decorations': [], 'nodeStyle': {}, 'lineHeight': '1.5'}
|
||||
}
|
||||
'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()
|
||||
|
||||
@@ -148,6 +148,9 @@ def convert_via_wix_api(markdown_content: str, access_token: str, base_url: str
|
||||
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:
|
||||
@@ -182,6 +185,28 @@ def convert_via_wix_api(markdown_content: str, access_token: str, base_url: str
|
||||
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 = {
|
||||
|
||||
@@ -27,7 +27,8 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
|
||||
"""
|
||||
seo_data = {
|
||||
'settings': {
|
||||
'keywords': []
|
||||
'keywords': [],
|
||||
'preventAutoRedirect': False # Required by Wix API schema
|
||||
},
|
||||
'tags': []
|
||||
}
|
||||
@@ -40,7 +41,8 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
|
||||
if focus_keyword:
|
||||
keywords_list.append({
|
||||
'term': str(focus_keyword),
|
||||
'isMain': True
|
||||
'isMain': True,
|
||||
'origin': 'USER' # Required by Wix API
|
||||
})
|
||||
|
||||
# Add additional keywords from blog_tags or other sources
|
||||
@@ -51,7 +53,8 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
|
||||
if tag_str and tag_str != focus_keyword: # Don't duplicate main keyword
|
||||
keywords_list.append({
|
||||
'term': tag_str,
|
||||
'isMain': False
|
||||
'isMain': False,
|
||||
'origin': 'USER' # Required by Wix API
|
||||
})
|
||||
|
||||
# Add social hashtags as keywords if available
|
||||
@@ -63,9 +66,17 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
|
||||
if hashtag_str and hashtag_str != focus_keyword:
|
||||
keywords_list.append({
|
||||
'term': hashtag_str,
|
||||
'isMain': False
|
||||
'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)
|
||||
@@ -89,13 +100,13 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
|
||||
})
|
||||
|
||||
# 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'
|
||||
'custom': True,
|
||||
'disabled': False
|
||||
'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
|
||||
|
||||
Reference in New Issue
Block a user