feat: ContentGuardianAgent, onboarding UX, Team Activity action wiring, docs, agent help modal

ContentGuardianAgent consolidation:
- Merge 3 duplicate classes into single source in specialized/content_guardian.py
- Watchdog audit_committee() with heuristic scoring, coverage gaps, overlaps, alerts
- Remove misleading rejection_rate() helper; use acceptance_rate directly
- Integrate audit + alerts + trend signals into today_workflow_service.py

Team Activity page:
- QualityAuditPanel: health ring, per-agent critiques, coverage gaps, overlaps
- TrendSignalsPanel: opportunity cards with urgency/impact/coverage bars
- AlertBanner: persistent dismiss via POST /alerts/{id}/mark-read
- AgentHelpModal: dialog showing all 8 agents with descriptions, tools, schedule
- QualityAuditPanel action buttons: Fill gap -> /content-planning, Resolve overlap, View CTA on alerts/issues
- TrendSignalsPanel action buttons: Create content from this trend -> /blog-writer with trend context state

Onboarding system:
- Step 4 validation: no auto-pass via basic_ready; requires persona data or explicit progression
- Step 5 validation: logs warning on auto-pass without integration data
- OnboardingCompletionService: single DB session, transactional task creation, upsert pattern
- Business-without-website: nullable website_url on SIFIndexingTask and MarketTrendsTask
- DeepCompetitorAnalysisExecutor: 5-min timeout, 10-competitor cap, asyncio.wait_for
- Persona generation: async with 30s timeout, falls back to scheduler
- OnboardingProgressService.reset_onboarding(): resets session + pauses all DB tasks
- OnboardingControlService.reset_onboarding(): also cancels APScheduler jobs
- FinalStep TaskSchedulingPanel: shows scheduled/failed tasks after completion, 8s auto-redirect
- onboarding_completed agent activity event logged to feed

Documentation:
- docs-site/features/onboarding/: overview, steps, scheduler-tasks, technical-reference (4 pages)
- docs-site/mkdocs.yml: added Onboarding System nav section
- docs-site/features/sif-agents/: overview, agent-directory, committee-system, content-guardian (4 pages)
- docs-site/features/team-activity/: overview, quality-audit, trend-signals, alert-system (4 pages)
- docs-site/features/todays-workflow/: updated overview, technical-architecture, workflow-guide, api-reference
This commit is contained in:
ajaysi
2026-06-01 12:24:31 +05:30
parent 9b472f1c18
commit 923fa671fe
90 changed files with 8914 additions and 2731 deletions

View File

@@ -47,7 +47,10 @@ class WixAuthService:
'code_verifier': code_verifier,
}
token_url = f'{self.base_url}/oauth2/token'
logger.info(f"Wix token exchange: client_id={self.client_id}, redirect_uri={self.redirect_uri}, code_verifier_prefix={code_verifier[:10]}...")
response = requests.post(token_url, headers=headers, data=data)
if response.status_code != 200:
logger.error(f"Wix token exchange failed: {response.status_code} {response.text}")
response.raise_for_status()
return response.json()

View File

@@ -55,19 +55,20 @@ def get_wix_headers(
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)")
logger.debug("Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
elif token.startswith('IST.'):
# Wix Headless API key - send as-is, no Bearer
headers['Authorization'] = token
logger.debug("Using Wix API key for authorization (IST. format detected)")
else:
# Count dots - JWT has exactly 2 dots
# Standard JWT has exactly 2 dots separating header.payload.signature
dot_count = token.count('.')
if dot_count == 2 and len(token) < 500:
# Likely OAuth JWT token - use Bearer prefix
if dot_count == 2:
headers['Authorization'] = f'Bearer {token}'
logger.debug(f"Using OAuth Bearer token (JWT format detected)")
logger.debug("Using OAuth Bearer token (JWT format: 2 dots 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)")
logger.debug("Using token as-is (non-JWT format detected)")
if client_id:
headers['wix-client-id'] = client_id
@@ -125,8 +126,10 @@ def should_use_api_key(access_token: Optional[str] = None) -> bool:
access_token = str(access_token)
token = access_token.strip()
if token.count('.') != 2 or len(token) > 500:
if token.startswith('OauthNG.JWS.'):
return False
if token.startswith('IST.'):
return True
return False
# Standard JWT has exactly 2 dots
return token.count('.') != 2

View File

@@ -2,20 +2,22 @@ from typing import Any, Dict, List, Optional
import requests
from loguru import logger
from .retry import wix_api_call_with_retry, WixAPIError
class WixBlogService:
"""Service for Wix Blog API operations with retry logic and error handling."""
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]:
"""Build headers with automatic token type detection."""
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):
@@ -28,20 +30,18 @@ class WixBlogService:
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
elif token.startswith('IST.'):
h['Authorization'] = token
logger.debug("Using API key for authorization")
else:
# Standard JWT OAuth token (xxx.yyy.zzz format) - use Bearer prefix
logger.debug("Using Wix API key for authorization (IST. format detected)")
elif token.count('.') == 2:
h['Authorization'] = f'Bearer {token}'
logger.debug("Using OAuth Bearer token for authorization")
logger.debug("Using OAuth Bearer token for authorization (JWT: 2 dots)")
else:
h['Authorization'] = token
logger.debug("Using token as-is for authorization")
if self.client_id:
h['wix-client-id'] = self.client_id
@@ -50,12 +50,12 @@ 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]:
"""Create draft post with consolidated logging"""
"""Create draft post with retry logic and consolidated logging."""
from .logger import wix_logger
import json
import traceback as tb
# Build payload summary for logging
# Build payload summary for logging (safe, no sensitive data)
payload_summary = {}
if 'draftPost' in payload:
dp = payload['draftPost']
@@ -66,64 +66,114 @@ class WixBlogService:
}
request_headers = self.headers(access_token, extra_headers)
logger.debug(f"Wix API request headers: {list(request_headers.keys())}")
if 'wix-site-id' in request_headers:
logger.info(f"Wix API call includes wix-site-id: {request_headers['wix-site-id'][:8]}...")
else:
logger.warning("Wix API call MISSING wix-site-id header — this may fail for multi-site tokens")
url = f"{self.base_url}/blog/v3/draft-posts"
try:
response = requests.post(f"{self.base_url}/blog/v3/draft-posts", headers=request_headers, json=payload)
except TypeError as e:
logger.error(f"TypeError during requests.post in create_draft_post: {e}")
logger.error(f"Traceback: {tb.format_exc()}")
logger.error(f"access_token type: {type(access_token)}")
logger.error(f"payload type: {type(payload)}, keys: {list(payload.keys()) if isinstance(payload, dict) else 'N/A'}")
result = wix_api_call_with_retry('POST', url, request_headers, json_payload=payload, max_attempts=3)
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", 200, payload_summary, None)
return result
except WixAPIError as e:
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", e.status_code or 500, payload_summary, e.response_body)
logger.error(f"Wix create_draft_post failed after retries: HTTP {e.status_code} - {e.response_body}")
raise
except Exception as e:
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", 500, payload_summary, str(e)[:200])
logger.error(f"Unexpected error in create_draft_post: {e}")
raise
# 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()
"""Publish a draft post with retry logic."""
url = f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}/publish"
headers = self.headers(access_token, extra_headers)
try:
return wix_api_call_with_retry('POST', url, headers, max_attempts=3)
except WixAPIError as e:
logger.error(f"Wix publish_draft failed: HTTP {e.status_code} - {e.response_body}")
raise
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', [])
"""List blog categories with retry logic."""
url = f"{self.base_url}/blog/v3/categories"
headers = self.headers(access_token, extra_headers)
try:
result = wix_api_call_with_retry('GET', url, headers, max_attempts=3)
return result.get('categories', [])
except WixAPIError as e:
logger.error(f"Wix list_categories failed: HTTP {e.status_code}")
raise
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]:
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]:
"""Create a blog category with retry logic."""
url = f"{self.base_url}/blog/v3/categories"
headers = self.headers(access_token, extra_headers)
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()
try:
return wix_api_call_with_retry('POST', url, headers, json_payload=payload, max_attempts=3)
except WixAPIError as e:
logger.error(f"Wix create_category failed: HTTP {e.status_code}")
raise
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', [])
"""List blog tags with retry logic."""
url = f"{self.base_url}/blog/v3/tags"
headers = self.headers(access_token, extra_headers)
try:
result = wix_api_call_with_retry('GET', url, headers, max_attempts=3)
return result.get('tags', [])
except WixAPIError as e:
logger.error(f"Wix list_tags failed: HTTP {e.status_code}")
raise
def create_tag(self, access_token: str, label: str, language: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
def create_tag(self, access_token: str, label: str, language: Optional[str] = None,
extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Create a blog tag with retry logic."""
url = f"{self.base_url}/blog/v3/tags"
headers = self.headers(access_token, extra_headers)
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()
try:
return wix_api_call_with_retry('POST', url, headers, json_payload=payload, max_attempts=3)
except WixAPIError as e:
logger.error(f"Wix create_tag failed: HTTP {e.status_code}")
raise
def get_draft_post(self, access_token: str, draft_post_id: str,
extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Get a draft post by ID with retry logic."""
url = f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}"
headers = self.headers(access_token, extra_headers)
try:
return wix_api_call_with_retry('GET', url, headers, max_attempts=3)
except WixAPIError as e:
logger.error(f"Wix get_draft_post failed: HTTP {e.status_code}")
raise
def update_draft_post(self, access_token: str, draft_post_id: str, payload: Dict[str, Any],
extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Update a draft post with retry logic."""
url = f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}"
headers = self.headers(access_token, extra_headers)
try:
return wix_api_call_with_retry('PUT', url, headers, json_payload=payload, max_attempts=3)
except WixAPIError as e:
logger.error(f"Wix update_draft_post failed: HTTP {e.status_code}")
raise

View File

@@ -5,6 +5,7 @@ Handles blog post creation, validation, and publishing to Wix.
"""
import json
import os
import re
import uuid
import requests
@@ -193,6 +194,7 @@ def create_blog_post(
tag_ids: List[str] = None,
publish: bool = True,
seo_metadata: Dict[str, Any] = None,
site_id: str = None,
import_image_func = None,
lookup_categories_func = None,
lookup_tags_func = None,
@@ -220,111 +222,50 @@ def create_blog_post(
Returns:
Created blog post information
"""
if not member_id:
raise ValueError("memberId is required for third-party apps creating blog posts")
# ===== PRE-FLIGHT VALIDATION =====
errors = []
# 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.)
if not member_id:
errors.append("memberId is required for third-party apps creating blog posts")
title_clean = str(title).strip() if title else ""
if not title_clean:
errors.append("Title is required")
elif len(title_clean) > 200:
errors.append(f"Title is too long ({len(title_clean)} chars, max 200)")
# Ensure access_token is a string
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")
errors.append("access_token is required and must be a valid string or token object")
else:
access_token = normalized_token.strip()
if not access_token:
errors.append("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 = False # Disabled: full Ricos conversion now produces valid output
content_clean = str(content).strip() if content else ""
if not content_clean:
logger.warning("Content was empty, using default text")
content = "This is a post from ALwrity."
elif len(content_clean) > 100000:
errors.append(f"Content is too long ({len(content_clean)} chars, max 100,000)")
if errors:
raise ValueError(f"Wix publish validation failed: {'; '.join(errors)}")
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': []
}
}]
}]
}
# 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
from .utils import decode_wix_token, extract_meta_from_token
token_data = decode_wix_token(access_token)
if 'scope' in token_data:
scopes = token_data.get('scope')
@@ -332,17 +273,9 @@ def create_blog_post(
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')
logger.error("Wix: Token missing BLOG scopes - verify OAuth app permissions")
meta_info = extract_meta_from_token(access_token)
meta_site_id = meta_info.get('metaSiteId')
except Exception:
pass
@@ -352,13 +285,12 @@ def create_blog_post(
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")
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")
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)
@@ -470,19 +402,20 @@ def create_blog_post(
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):
# import_image_to_wix now returns Optional[str] — None means failure
if media_id and isinstance(media_id, str) and media_id.strip():
blog_data['draftPost']['media'] = {
'wixMedia': {
'image': {'id': str(media_id).strip()}
'image': {'id': media_id.strip()}
},
'displayed': True,
'custom': True
}
logger.info(f"Cover image imported: {media_id[:16]}...")
else:
logger.warning(f"Invalid media_id type or value: {type(media_id)}, skipping media")
logger.warning(f"Cover image import returned no valid media_id (type={type(media_id)}). Continuing without cover image.")
except Exception as e:
logger.warning(f"Failed to import cover image: {e}")
logger.warning(f"Cover image import failed (non-fatal): {e}. Continuing without cover image.")
# Handle categories - can be either IDs (list of strings) or names (for lookup)
category_ids_to_use = None
@@ -558,34 +491,33 @@ def create_blog_post(
logger.debug("No SEO metadata provided to create_blog_post")
try:
# Extract wix-site-id from token if possible
# Extract wix-site-id from token, parameter, or env var
extra_headers = {}
try:
wix_site_id = site_id or os.getenv('WIX_SITE_ID')
if not wix_site_id:
from .utils import extract_meta_from_token
meta_info = extract_meta_from_token(access_token)
wix_site_id = meta_info.get('metaSiteId')
if wix_site_id:
extra_headers['wix-site-id'] = wix_site_id
logger.info(f"Using wix-site-id: {wix_site_id[:8]}... (source: {'param' if site_id else 'env' if os.getenv('WIX_SITE_ID') else 'token'})")
else:
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
if token_str.startswith('IST.'):
logger.error("❌ IST. API key requires WIX_SITE_ID environment variable or site_id parameter. "
"The token's tenant.id is the account ID, not the site ID. "
"Please set WIX_SITE_ID in your .env file to your Wix site's metaSiteId.")
else:
logger.warning("No wix-site-id found — API calls may fail if token requires it")
except Exception as e:
logger.debug(f"Could not extract wix-site-id from token: {e}")
try:
# 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):
@@ -595,8 +527,7 @@ def create_blog_post(
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):
@@ -606,46 +537,40 @@ def create_blog_post(
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 payload summary
logger.debug(f"Payload: draftPost keys={list(draft_post.keys())}, "
f"nodes={len(draft_post.get('richContent', {}).get('nodes', []))}, "
f"has_seo={'seoData' in draft_post}")
# Final deep validation: Serialize and deserialize to catch any JSON-serialization issues
try:
import json
json.dumps(blog_data, ensure_ascii=False)
except (TypeError, ValueError) as e:
logger.error(f"❌ Payload JSON serialization failed: {e}")
raise ValueError(f"Payload contains non-serializable data: {e}")
# Clean up None values that Wix API would reject
rc = blog_data['draftPost']['richContent']
for field in ['documentStyle', 'metadata']:
if field in rc and (rc[field] is None or rc[field] == "" or not isinstance(rc[field], dict)):
del rc[field]
logger.info(f"📤 Publishing to Wix: title='{blog_data['draftPost'].get('title', '')}', "
f"nodes={len(rc.get('nodes', []))}")
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 TypeError as e:
import traceback

View File

@@ -5,79 +5,71 @@ 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.
Parse inline markdown formatting (bold, italic, links, code, strikethrough) into Ricos text nodes.
Returns a list of text nodes with decorations.
Handles: **bold**, *italic*, [links](url), `code`, and combinations.
Handles: **bold**, *italic*, [links](url), `code`, ~strikethrough~, and combinations.
"""
if not text:
return [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'nodes': [],
'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 = []
def flush_text():
nonlocal current_text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [],
'textData': {'text': current_text, 'decorations': []}
})
current_text = ''
while i < len(text):
# Check for bold **text** (must come before single * check)
# Bold **text**
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 **
flush_text()
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
decs = node['textData'].get('decorations', []).copy()
if not any(d.get('type') == 'BOLD' for d in decs if isinstance(d, dict)):
decs.append({'type': 'BOLD'})
node['textData']['decorations'] = decs
nodes.append(node)
i = end_bold + 2
continue
# Check for link [text](url)
# Strikethrough ~text~
elif text[i] == '~':
flush_text()
end_strike = text.find('~', i + 1)
if end_strike != -1:
strike_text = text[i + 1:end_strike]
strike_nodes = parse_markdown_inline(strike_text)
for node in strike_nodes:
if node['type'] == 'TEXT':
decs = node['textData'].get('decorations', []).copy()
if not any(d.get('type') == 'STRIKETHROUGH' for d in decs if isinstance(d, dict)):
decs.append({'type': 'STRIKETHROUGH'})
node['textData']['decorations'] = decs
nodes.append(node)
i = end_strike + 1
continue
# 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 ]
flush_text()
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]
@@ -85,12 +77,10 @@ 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]
# 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
'nodes': [],
'textData': {
'text': link_text,
'decorations': [{
@@ -98,7 +88,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
'linkData': {
'link': {
'url': url,
'target': 'BLANK' # Wix API uses 'BLANK', not '_blank'
'target': 'BLANK'
}
}
}]
@@ -107,33 +97,17 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
i = url_end + 1
continue
# Check for code `text`
# Inline 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 `
flush_text()
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
# Wix doesn't have a CODE decoration, but we can preserve the text
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'nodes': [],
'textData': {
'text': code_text,
'decorations': [] # CODE is not a valid decoration in Wix API
@@ -142,39 +116,21 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
i = code_end + 1
continue
# Check for italic *text* (only if not part of **)
# Italic *text* (must come after ** check)
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 **)
flush_text()
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
decs = node['textData'].get('decorations', []).copy()
if not any(d.get('type') == 'ITALIC' for d in decs if isinstance(d, dict)):
decs.append({'type': 'ITALIC'})
node['textData']['decorations'] = decs
nodes.append(node)
i = italic_end + 1
continue
@@ -183,58 +139,116 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
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()
}
})
flush_text()
# 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': []
}
'nodes': [],
'textData': {'text': text, 'decorations': []}
})
return nodes
def _make_code_block_node(code_text: str, language: str = '') -> Dict[str, Any]:
"""Create a Ricos CODE_BLOCK node."""
lines = code_text.split('\n')
text_nodes = []
for line in lines:
text_nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [],
'textData': {'text': line, 'decorations': []}
})
return {
'id': str(uuid.uuid4()),
'type': 'CODE_BLOCK',
'nodes': text_nodes,
'codeBlockData': {
'language': language or 'text',
'textWrap': True
}
}
def _make_horizontal_rule_node() -> Dict[str, Any]:
"""Create a Ricos DIVIDER node."""
return {
'id': str(uuid.uuid4()),
'type': 'DIVIDER',
'nodes': [],
'dividerData': {
'type': 'LINE',
'lineStyle': {
'width': 'LARGE',
'alignment': 'CENTER'
}
}
}
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.
Supports:
- Headings (# to ######)
- Paragraphs with inline formatting
- Unordered lists (-, *)
- Ordered lists (1., 2.)
- Blockquotes (>)
- Code blocks (```language ... ```)
- Inline images (![alt](url))
- Horizontal rules (---, ***, ___)
"""
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()
line = lines[i]
stripped = line.strip()
if not line:
if not stripped:
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()
# Code blocks (```language ... ```)
if stripped.startswith('```'):
language = stripped[3:].strip() or ''
code_lines = []
i += 1
while i < len(lines):
if lines[i].strip() == '```':
i += 1
break
code_lines.append(lines[i])
i += 1
code_text = '\n'.join(code_lines)
if code_text.strip():
nodes.append(_make_code_block_node(code_text, language))
continue
# Horizontal rules
if re.match(r'^(---+|\*\*\*|___+)$', stripped):
nodes.append(_make_horizontal_rule_node())
i += 1
continue
# Headings
if stripped.startswith('#'):
level = len(stripped) - len(stripped.lstrip('#'))
heading_text = stripped.lstrip('# ').strip()
text_nodes = parse_markdown_inline(heading_text)
nodes.append({
'id': node_id,
@@ -243,42 +257,38 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
'headingData': {'level': min(level, 6)}
})
i += 1
continue
# Check for blockquotes
elif line.startswith('>'):
quote_text = line.lstrip('> ').strip()
# Continue reading consecutive blockquote lines
quote_lines = [quote_text]
# Blockquotes
if stripped.startswith('>'):
quote_lines = [stripped.lstrip('> ').strip()]
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
# Wix API: omit empty data objects, don't include them as {}
paragraph_node = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
}
blockquote_node = {
nodes.append({
'id': node_id,
'type': 'BLOCKQUOTE',
'nodes': [paragraph_node],
}
nodes.append(blockquote_node)
})
continue
# 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] != '*')):
# Unordered lists
if (stripped.startswith('- ') or stripped.startswith('* ') or
(stripped.startswith('-') and len(stripped) > 1 and stripped[1] != '-') or
(stripped.startswith('*') and len(stripped) > 1 and stripped[1] != '*')):
list_items = []
list_marker = '- ' if line.startswith('-') else '* '
# Process list items
list_marker = '- ' if stripped.startswith('-') else '* '
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] != '*'))
@@ -286,12 +296,9 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
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('*'):
elif current_line.startswith('-') or current_line.startswith('*'):
item_text = current_line[1:].strip()
else:
item_text = current_line
@@ -302,52 +309,41 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
# 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)):
if (next_line.startswith(' ') and
(next_line.strip().startswith('- ') or next_line.strip().startswith('* '))):
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('*'):
elif nested_text.startswith('-') or 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,
}
list_item_node = {
'id': item_node_id,
list_node_items.append({
'id': str(uuid.uuid4()),
'type': 'LIST_ITEM',
'nodes': [paragraph_node]
}
list_node_items.append(list_item_node)
})
bulleted_list_node = {
nodes.append({
'id': node_id,
'type': 'BULLETED_LIST',
'nodes': list_node_items,
}
nodes.append(bulleted_list_node)
})
continue
# Check for ordered lists
elif re.match(r'^\d+\.\s+', line):
# Ordered lists
if re.match(r'^\d+\.\s+', stripped):
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())
@@ -359,35 +355,30 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
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,
}
list_item_node = {
'id': item_node_id,
list_node_items.append({
'id': str(uuid.uuid4()),
'type': 'LIST_ITEM',
'nodes': [paragraph_node]
}
list_node_items.append(list_item_node)
})
ordered_list_node = {
nodes.append({
'id': node_id,
'type': 'ORDERED_LIST',
'nodes': list_node_items,
}
nodes.append(ordered_list_node)
})
continue
# Check for images
elif line.startswith('!['):
img_match = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', line)
# Images
if stripped.startswith('!['):
img_match = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', stripped)
if img_match:
alt_text = img_match.group(1)
img_url = img_match.group(2)
@@ -407,62 +398,52 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
}
})
i += 1
continue
# Regular paragraph
else:
# Collect consecutive non-empty lines as paragraph content
para_lines = [line]
para_lines = [stripped]
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
next_line.startswith('```') or
re.match(r'^(---+|\*\*\*|___+)$', next_line) or
re.match(r'^\d+\.\s+', next_line)):
break
para_lines.append(next_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,
}
nodes.append(paragraph_node)
para_text = ' '.join(para_lines)
text_nodes = parse_markdown_inline(para_text)
if text_nodes:
nodes.append({
'id': node_id,
'type': 'PARAGRAPH',
'nodes': text_nodes,
})
# Ensure at least one node exists
# Wix API: omit empty data objects, don't include them as {}
if not nodes:
fallback_paragraph = {
nodes.append({
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'nodes': [],
'textData': {
'text': content[:500] if content else "This is a post from ALwrity.",
'decorations': []
}
}],
}
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
}
return {'nodes': nodes}

View File

@@ -1,17 +1,33 @@
from typing import Any, Dict
from typing import Any, Dict, Optional
import requests
from loguru import logger
from .retry import wix_api_call_with_retry, WixAPIError
class WixMediaService:
"""Service for Wix Media Manager operations with retry logic and error handling."""
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]:
def import_image(self, access_token: str, image_url: str, display_name: str) -> Optional[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
Args:
access_token: Valid access token
image_url: URL of the image to import
display_name: Display name for the image
Returns:
Media result dict with 'file' key, or None on failure
Raises:
WixAPIError: On non-retryable failure or after retries exhausted
"""
headers = {
'Authorization': f'Bearer {access_token}',
@@ -22,10 +38,54 @@ class WixMediaService:
'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()
try:
result = wix_api_call_with_retry(
'POST', endpoint, headers, json_payload=payload, max_attempts=2
)
if result and 'file' in result and 'id' in result['file']:
logger.info(f"Image imported successfully: {result['file']['id'][:16]}...")
return result
else:
logger.warning(f"Image import returned unexpected structure: {list(result.keys()) if isinstance(result, dict) else type(result)}")
return None
except WixAPIError as e:
if e.status_code == 403:
logger.error(f"Image import forbidden (403): OAuth app may lack MEDIA.SITE_MEDIA_FILES_IMPORT scope")
elif e.status_code == 400:
logger.error(f"Image import bad request (400): {e.response_body}")
elif e.status_code == 404:
logger.error(f"Image import endpoint not found (404) — Wix Media API may not be available for this site")
else:
logger.error(f"Image import failed after retries: HTTP {e.status_code} - {e.response_body}")
raise
except Exception as e:
logger.error(f"Unexpected error importing image: {e}")
raise
def get_image_url(self, access_token: str, media_id: str) -> Optional[str]:
"""
Get public URL for a Wix media item.
Args:
access_token: Valid access token
media_id: Wix media ID
Returns:
Public URL string, or None
"""
url = f"{self.base_url}/site-media/v1/files/{media_id}"
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
}
try:
result = wix_api_call_with_retry('GET', url, headers, max_attempts=2)
if result and 'file' in result:
return result['file'].get('url')
return None
except Exception as e:
logger.warning(f"Failed to get image URL for {media_id}: {e}")
return None

View File

@@ -0,0 +1,168 @@
"""
Retry utilities for Wix API calls with exponential backoff.
Production-grade retry logic that respects Wix rate limits and handles
transient failures gracefully.
"""
import time
import random
from typing import Callable, TypeVar, Optional
from loguru import logger
T = TypeVar('T')
class WixAPIError(Exception):
"""Custom exception for Wix API errors with status code context."""
def __init__(self, message: str, status_code: Optional[int] = None, response_body: Optional[str] = None):
super().__init__(message)
self.status_code = status_code
self.response_body = response_body
def is_retryable(self) -> bool:
"""Determine if this error is retryable based on status code."""
if self.status_code is None:
return True # Network errors are retryable
# 429 = rate limit, 502/503/504 = gateway errors, 500 = internal server error (sometimes transient)
return self.status_code in (429, 500, 502, 503, 504)
def is_rate_limit(self) -> bool:
"""Check if this is a rate limit error."""
return self.status_code == 429
def with_retry(
fn: Callable[[], T],
max_attempts: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0,
retryable_exceptions: tuple = (Exception,),
operation_name: str = "Wix API call"
) -> T:
"""
Execute a function with exponential backoff retry logic.
Args:
fn: Function to execute (should make the API call)
max_attempts: Maximum number of attempts (default: 3)
base_delay: Initial delay in seconds (default: 1.0)
max_delay: Maximum delay in seconds (default: 30.0)
retryable_exceptions: Tuple of exception types to retry on
operation_name: Name for logging
Returns:
Result of fn()
Raises:
WixAPIError: If all retries are exhausted
Exception: If a non-retryable exception occurs
"""
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return fn()
except WixAPIError as e:
last_exception = e
if attempt >= max_attempts:
break
if not e.is_retryable():
logger.warning(f"{operation_name}: non-retryable error (HTTP {e.status_code}), failing fast")
raise
# Calculate delay with exponential backoff and jitter
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
# Add jitter (±25%) to prevent thundering herd
jitter = delay * 0.25
actual_delay = delay + random.uniform(-jitter, jitter)
actual_delay = max(0.1, actual_delay) # Minimum 100ms delay
if e.is_rate_limit():
# For rate limits, use a longer base delay
actual_delay = max(actual_delay, 2.0)
logger.warning(f"{operation_name}: rate limited (429), waiting {actual_delay:.1f}s before retry {attempt + 1}/{max_attempts}")
else:
logger.warning(f"{operation_name}: attempt {attempt}/{max_attempts} failed (HTTP {e.status_code}), waiting {actual_delay:.1f}s before retry")
time.sleep(actual_delay)
except retryable_exceptions as e:
last_exception = e
if attempt >= max_attempts:
break
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
jitter = delay * 0.25
actual_delay = delay + random.uniform(-jitter, jitter)
actual_delay = max(0.1, actual_delay)
logger.warning(f"{operation_name}: attempt {attempt}/{max_attempts} failed ({type(e).__name__}), waiting {actual_delay:.1f}s before retry")
time.sleep(actual_delay)
# All retries exhausted
if last_exception:
if isinstance(last_exception, WixAPIError):
raise last_exception
raise WixAPIError(f"{operation_name}: failed after {max_attempts} attempts: {last_exception}")
raise WixAPIError(f"{operation_name}: failed after {max_attempts} attempts")
def wix_api_call_with_retry(
method: str,
url: str,
headers: dict,
json_payload: Optional[dict] = None,
max_attempts: int = 3
) -> dict:
"""
Convenience wrapper for making Wix API calls with retry logic.
Args:
method: HTTP method ('GET', 'POST', etc.)
url: Full API URL
headers: Request headers
json_payload: Optional JSON payload for POST/PUT
max_attempts: Maximum retry attempts
Returns:
Parsed JSON response
Raises:
WixAPIError: On failure after retries
"""
import requests
def _call():
if method.upper() == 'GET':
resp = requests.get(url, headers=headers, timeout=30)
elif method.upper() == 'POST':
resp = requests.post(url, headers=headers, json=json_payload, timeout=30)
elif method.upper() == 'PUT':
resp = requests.put(url, headers=headers, json=json_payload, timeout=30)
elif method.upper() == 'DELETE':
resp = requests.delete(url, headers=headers, timeout=30)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
if resp.status_code >= 400:
body = None
try:
body = resp.text[:500]
except:
body = str(resp.content)[:500]
raise WixAPIError(
f"Wix API {method} {url} failed: HTTP {resp.status_code}",
status_code=resp.status_code,
response_body=body
)
return resp.json()
return with_retry(
_call,
max_attempts=max_attempts,
operation_name=f"Wix {method} {url.split('/')[-1]}"
)

View File

@@ -85,24 +85,45 @@ def decode_wix_token(access_token: str) -> Dict[str, Any]:
if token_str.startswith('OauthNG.JWS.'):
jwt_part = token_str[12:]
return jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
if token_str.startswith('IST.'):
jwt_part = token_str[4:]
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_data_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
data_payload = payload.get('data', {})
if isinstance(data_payload, str):
try:
data_payload = json.loads(data_payload)
except Exception:
data_payload = {}
return data_payload if isinstance(data_payload, dict) else {}
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 {
data_payload = _extract_data_payload(payload)
instance = (data_payload or {}).get('instance', {}) or {}
result = {
'siteMemberId': instance.get('siteMemberId'),
'metaSiteId': instance.get('metaSiteId'),
'permissions': instance.get('permissions'),
}
# Only fall back to tenant.id for OAuth tokens (not IST. API keys)
# IST. tokens have tenant.id = account_id, which is NOT the site metaSiteId
token_str = str(access_token)
if not result.get('metaSiteId') and not token_str.startswith('IST.'):
tenant = data_payload.get('tenant', {}) or {}
tenant_id = tenant.get('id')
if tenant_id:
result['metaSiteId'] = tenant_id
if not result.get('metaSiteId'):
meta_site_id = payload.get('metaSiteId') or payload.get('site_id')
if meta_site_id:
result['metaSiteId'] = meta_site_id
return result
except Exception:
return {'siteMemberId': None, 'metaSiteId': None, 'permissions': None}