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
586 lines
27 KiB
Python
586 lines
27 KiB
Python
"""
|
|
Blog Post Publisher for Wix
|
|
|
|
Handles blog post creation, validation, and publishing to Wix.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import uuid
|
|
import requests
|
|
import jwt
|
|
from typing import Dict, Any, Optional, List
|
|
from loguru import logger
|
|
from services.integrations.wix.blog import WixBlogService
|
|
from services.integrations.wix.content import convert_content_to_ricos
|
|
from services.integrations.wix.ricos_converter import convert_via_wix_api
|
|
from services.integrations.wix.seo import build_seo_data
|
|
from services.integrations.wix.logger import wix_logger
|
|
from services.integrations.wix.utils import normalize_token_string
|
|
|
|
|
|
def validate_ricos_content(ricos_content: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Validate and normalize Ricos document structure.
|
|
|
|
Args:
|
|
ricos_content: Ricos document dict
|
|
|
|
Returns:
|
|
Validated and normalized Ricos document
|
|
"""
|
|
# Validate Ricos document structure before using
|
|
if not ricos_content or not isinstance(ricos_content, dict):
|
|
logger.error("Invalid Ricos content - not a dict")
|
|
raise ValueError("Failed to convert content to valid Ricos format")
|
|
|
|
if 'type' not in ricos_content:
|
|
ricos_content['type'] = 'DOCUMENT'
|
|
logger.debug("Added missing richContent type 'DOCUMENT'")
|
|
if ricos_content.get('type') != 'DOCUMENT':
|
|
logger.warning(f"richContent type expected 'DOCUMENT', got {ricos_content.get('type')}, correcting")
|
|
ricos_content['type'] = 'DOCUMENT'
|
|
|
|
if 'id' not in ricos_content or not isinstance(ricos_content.get('id'), str):
|
|
ricos_content['id'] = str(uuid.uuid4())
|
|
logger.debug("Added missing richContent id")
|
|
|
|
if 'nodes' not in ricos_content:
|
|
logger.warning("Ricos document missing 'nodes' field, adding empty nodes array")
|
|
ricos_content['nodes'] = []
|
|
|
|
logger.debug(f"Ricos document structure: nodes={len(ricos_content.get('nodes', []))}")
|
|
|
|
# Validate richContent is a proper object with nodes array
|
|
# Per Wix API: richContent must be a RichContent object with nodes array
|
|
if not isinstance(ricos_content, dict):
|
|
raise ValueError(f"richContent must be a dict object, got {type(ricos_content)}")
|
|
|
|
# Ensure nodes array exists and is valid
|
|
if 'nodes' not in ricos_content:
|
|
logger.warning("richContent missing 'nodes', adding empty array")
|
|
ricos_content['nodes'] = []
|
|
|
|
if not isinstance(ricos_content['nodes'], list):
|
|
raise ValueError(f"richContent.nodes must be a list, got {type(ricos_content['nodes'])}")
|
|
|
|
# Recursive function to validate and fix nodes at any depth
|
|
def validate_node_recursive(node: Dict[str, Any], path: str = "root") -> None:
|
|
"""
|
|
Recursively validate a node and all its nested children, ensuring:
|
|
1. All required data fields exist for each node type
|
|
2. All 'nodes' arrays are proper lists
|
|
3. No None values in critical fields
|
|
"""
|
|
if not isinstance(node, dict):
|
|
logger.error(f"{path}: Node is not a dict: {type(node)}")
|
|
return
|
|
|
|
# Ensure type and id exist
|
|
if 'type' not in node:
|
|
logger.error(f"{path}: Missing 'type' field - REQUIRED")
|
|
node['type'] = 'PARAGRAPH' # Default fallback
|
|
if 'id' not in node:
|
|
node['id'] = str(uuid.uuid4())
|
|
logger.debug(f"{path}: Added missing 'id'")
|
|
|
|
node_type = node.get('type')
|
|
|
|
# CRITICAL: Per Wix API schema, data fields like paragraphData, bulletedListData, etc.
|
|
# are OPTIONAL and should be OMITTED entirely when empty, not included as {}
|
|
# Only validate fields that have required properties
|
|
|
|
# Special handling: Remove listItemData if it exists (not in Wix API schema)
|
|
if node_type == 'LIST_ITEM' and 'listItemData' in node:
|
|
logger.debug(f"{path}: Removing incorrect listItemData field from LIST_ITEM")
|
|
del node['listItemData']
|
|
|
|
# Only validate HEADING nodes - they require headingData with level property
|
|
if node_type == 'HEADING':
|
|
if 'headingData' not in node or not isinstance(node.get('headingData'), dict):
|
|
logger.warning(f"{path} (HEADING): Missing headingData, adding default level 1")
|
|
node['headingData'] = {'level': 1}
|
|
elif 'level' not in node['headingData']:
|
|
logger.warning(f"{path} (HEADING): Missing level in headingData, adding default")
|
|
node['headingData']['level'] = 1
|
|
|
|
# TEXT nodes must have textData
|
|
if node_type == 'TEXT':
|
|
if 'textData' not in node or not isinstance(node.get('textData'), dict):
|
|
logger.error(f"{path} (TEXT): Missing/invalid textData - node will be problematic")
|
|
node['textData'] = {'text': '', 'decorations': []}
|
|
|
|
# LINK and IMAGE nodes must have their data fields
|
|
if node_type == 'LINK' and ('linkData' not in node or not isinstance(node.get('linkData'), dict)):
|
|
logger.error(f"{path} (LINK): Missing/invalid linkData - node will be problematic")
|
|
if node_type == 'IMAGE' and ('imageData' not in node or not isinstance(node.get('imageData'), dict)):
|
|
logger.error(f"{path} (IMAGE): Missing/invalid imageData - node will be problematic")
|
|
|
|
# Remove None values from any data fields that exist (Wix API rejects None)
|
|
for data_field in ['headingData', 'paragraphData', 'blockquoteData', 'bulletedListData',
|
|
'orderedListData', 'textData', 'linkData', 'imageData']:
|
|
if data_field in node and isinstance(node[data_field], dict):
|
|
data_value = node[data_field]
|
|
keys_to_remove = [k for k, v in data_value.items() if v is None]
|
|
if keys_to_remove:
|
|
logger.debug(f"{path} ({node_type}): Removing None values from {data_field}: {keys_to_remove}")
|
|
for key in keys_to_remove:
|
|
del data_value[key]
|
|
|
|
# Ensure 'nodes' field exists for container nodes
|
|
container_types = ['HEADING', 'PARAGRAPH', 'BLOCKQUOTE', 'LIST_ITEM', 'LINK',
|
|
'BULLETED_LIST', 'ORDERED_LIST']
|
|
if node_type in container_types:
|
|
if 'nodes' not in node:
|
|
logger.warning(f"{path} ({node_type}): Missing 'nodes' field, adding empty array")
|
|
node['nodes'] = []
|
|
elif not isinstance(node['nodes'], list):
|
|
logger.error(f"{path} ({node_type}): Invalid 'nodes' field (not a list), fixing")
|
|
node['nodes'] = []
|
|
|
|
# Recursively validate all nested nodes
|
|
for nested_idx, nested_node in enumerate(node['nodes']):
|
|
nested_path = f"{path}.nodes[{nested_idx}]"
|
|
validate_node_recursive(nested_node, nested_path)
|
|
|
|
# Validate all top-level nodes recursively
|
|
for idx, node in enumerate(ricos_content['nodes']):
|
|
validate_node_recursive(node, f"nodes[{idx}]")
|
|
|
|
# Ensure documentStyle exists and is a dict (required by Wix API when provided)
|
|
if 'metadata' not in ricos_content or not isinstance(ricos_content.get('metadata'), dict):
|
|
ricos_content['metadata'] = {'version': 1, 'id': str(uuid.uuid4())}
|
|
logger.debug("Added default metadata to richContent")
|
|
else:
|
|
ricos_content['metadata'].setdefault('version', 1)
|
|
ricos_content['metadata'].setdefault('id', str(uuid.uuid4()))
|
|
|
|
if 'documentStyle' not in ricos_content or not isinstance(ricos_content.get('documentStyle'), dict):
|
|
ricos_content['documentStyle'] = {
|
|
'paragraph': {
|
|
'decorations': [],
|
|
'nodeStyle': {},
|
|
'lineHeight': '1.5'
|
|
}
|
|
}
|
|
logger.debug("Added default documentStyle to richContent")
|
|
|
|
logger.debug(f"✅ Validated richContent: {len(ricos_content['nodes'])} nodes, has_metadata={bool(ricos_content.get('metadata'))}, has_documentStyle={bool(ricos_content.get('documentStyle'))}")
|
|
|
|
return ricos_content
|
|
|
|
|
|
def validate_payload_no_none(obj, path=""):
|
|
"""Recursively validate that no None values exist in the payload"""
|
|
if obj is None:
|
|
raise ValueError(f"Found None value at path: {path}")
|
|
if isinstance(obj, dict):
|
|
for key, value in obj.items():
|
|
validate_payload_no_none(value, f"{path}.{key}" if path else key)
|
|
elif isinstance(obj, list):
|
|
for idx, item in enumerate(obj):
|
|
validate_payload_no_none(item, f"{path}[{idx}]" if path else f"[{idx}]")
|
|
|
|
|
|
def create_blog_post(
|
|
blog_service: WixBlogService,
|
|
access_token: str,
|
|
title: str,
|
|
content: str,
|
|
member_id: str,
|
|
cover_image_url: str = None,
|
|
category_ids: List[str] = None,
|
|
tag_ids: List[str] = None,
|
|
publish: bool = True,
|
|
seo_metadata: Dict[str, Any] = None,
|
|
site_id: str = None,
|
|
import_image_func = None,
|
|
lookup_categories_func = None,
|
|
lookup_tags_func = None,
|
|
base_url: str = 'https://www.wixapis.com'
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create and optionally publish a blog post on Wix
|
|
|
|
Args:
|
|
blog_service: WixBlogService instance
|
|
access_token: Valid access token
|
|
title: Blog post title
|
|
content: Blog post content (markdown)
|
|
member_id: Required for third-party apps - the member ID of the post author
|
|
cover_image_url: Optional cover image URL
|
|
category_ids: Optional list of category IDs or names
|
|
tag_ids: Optional list of tag IDs or names
|
|
publish: Whether to publish immediately or save as draft
|
|
seo_metadata: Optional SEO metadata dict
|
|
import_image_func: Function to import images (optional)
|
|
lookup_categories_func: Function to lookup/create categories (optional)
|
|
lookup_tags_func: Function to lookup/create tags (optional)
|
|
base_url: Wix API base URL
|
|
|
|
Returns:
|
|
Created blog post information
|
|
"""
|
|
# ===== PRE-FLIGHT VALIDATION =====
|
|
errors = []
|
|
|
|
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:
|
|
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")
|
|
|
|
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)
|
|
|
|
from .auth_utils import get_wix_headers
|
|
|
|
# Headers for blog post creation (use user's OAuth token)
|
|
headers = get_wix_headers(access_token)
|
|
|
|
# Quick token/permission check (only log if issues found)
|
|
has_blog_scope = None
|
|
meta_site_id = None
|
|
try:
|
|
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')
|
|
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")
|
|
meta_info = extract_meta_from_token(access_token)
|
|
meta_site_id = meta_info.get('metaSiteId')
|
|
except Exception:
|
|
pass
|
|
|
|
# Quick permission test (only log failures)
|
|
try:
|
|
test_headers = get_wix_headers(access_token)
|
|
import requests
|
|
test_response = requests.get(f"{base_url}/blog/v3/categories", headers=test_headers, timeout=5)
|
|
if test_response.status_code == 403:
|
|
logger.error("Wix: Permission denied - OAuth app missing BLOG.CREATE-DRAFT")
|
|
elif test_response.status_code == 401:
|
|
logger.error("Wix: Unauthorized - token may be expired")
|
|
except Exception:
|
|
pass
|
|
|
|
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']}
|
|
|
|
# SAFE ITEM 4: Prepend H1 title node if content doesn't start with one.
|
|
# The markdown typically starts at ## (H2) because the title is separate,
|
|
# but Wix renders the richContent as the full post body including the title.
|
|
# Without an H1, the post looks like it has no heading.
|
|
existing_first = ricos_content['nodes'][0] if ricos_content['nodes'] else None
|
|
has_h1 = existing_first and existing_first.get('type') == 'HEADING' and existing_first.get('headingData', {}).get('level') == 1
|
|
if not has_h1 and title:
|
|
title_node = {
|
|
'id': str(uuid.uuid4()),
|
|
'type': 'HEADING',
|
|
'nodes': [{
|
|
'id': str(uuid.uuid4()),
|
|
'type': 'TEXT',
|
|
'nodes': [],
|
|
'textData': {
|
|
'text': str(title).strip(),
|
|
'decorations': []
|
|
}
|
|
}],
|
|
'headingData': {'level': 1}
|
|
}
|
|
ricos_content['nodes'] = [title_node] + ricos_content['nodes']
|
|
logger.debug(f"Prepended H1 title node: '{str(title).strip()[:50]}'")
|
|
|
|
logger.debug(f"✅ richContent structure validated: {len(ricos_content['nodes'])} nodes, keys: {list(ricos_content.keys())}")
|
|
|
|
# Minimal payload per Wix docs: title, memberId, and richContent
|
|
# CRITICAL: Only include fields that have valid values (no None, no empty strings for required fields)
|
|
blog_data = {
|
|
'draftPost': {
|
|
'title': str(title).strip() if title else "Untitled",
|
|
'memberId': str(member_id).strip(), # Required for third-party apps (validated above)
|
|
'richContent': ricos_content, # Must be a valid Ricos object with ONLY 'nodes'
|
|
'language': 'en',
|
|
},
|
|
'publish': bool(publish),
|
|
'fieldsets': ['URL'] # Simplified fieldsets
|
|
}
|
|
|
|
# SAFE ITEM 1: Auto-generate seoSlug from title if not provided by SEO metadata
|
|
# Wix uses this for the URL path (e.g. /post/my-blog-title)
|
|
slug_source = None
|
|
if seo_metadata and seo_metadata.get('url_slug'):
|
|
slug_source = str(seo_metadata['url_slug']).strip()
|
|
elif title:
|
|
slug_source = re.sub(r'[^a-z0-9]+', '-', str(title).strip().lower()).strip('-')
|
|
slug_source = slug_source[:60].rstrip('-')
|
|
if slug_source:
|
|
blog_data['draftPost']['seoSlug'] = slug_source
|
|
|
|
# SAFE ITEM 3: Better excerpt — prefer meta_description, then first plain-text paragraph
|
|
excerpt = None
|
|
if seo_metadata and seo_metadata.get('meta_description'):
|
|
excerpt = str(seo_metadata['meta_description']).strip()[:200]
|
|
if not excerpt and content:
|
|
for node in ricos_content['nodes']:
|
|
if node.get('type') == 'PARAGRAPH':
|
|
texts = []
|
|
for child in node.get('nodes', []):
|
|
if child.get('type') == 'TEXT' and child.get('textData', {}).get('text'):
|
|
texts.append(child['textData']['text'])
|
|
if texts:
|
|
excerpt = ' '.join(texts).strip()[:200]
|
|
break
|
|
if excerpt:
|
|
blog_data['draftPost']['excerpt'] = excerpt
|
|
|
|
# Add cover image if provided
|
|
if cover_image_url and import_image_func:
|
|
try:
|
|
media_id = import_image_func(access_token, cover_image_url, f'Cover: {title}')
|
|
# 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': media_id.strip()}
|
|
},
|
|
'displayed': True,
|
|
'custom': True
|
|
}
|
|
logger.info(f"Cover image imported: {media_id[:16]}...")
|
|
else:
|
|
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"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
|
|
if category_ids:
|
|
# Check if these are IDs (UUIDs) or names
|
|
if isinstance(category_ids, list) and len(category_ids) > 0:
|
|
# Assume IDs if first item looks like UUID (has hyphens and is long)
|
|
first_item = str(category_ids[0])
|
|
if '-' in first_item and len(first_item) > 30:
|
|
category_ids_to_use = category_ids
|
|
elif lookup_categories_func:
|
|
# These are names, need to lookup/create
|
|
extra_headers = {}
|
|
if 'wix-site-id' in headers:
|
|
extra_headers['wix-site-id'] = headers['wix-site-id']
|
|
category_ids_to_use = lookup_categories_func(
|
|
access_token, category_ids, extra_headers if extra_headers else None
|
|
)
|
|
|
|
# Handle tags - can be either IDs (list of strings) or names (for lookup)
|
|
tag_ids_to_use = None
|
|
if tag_ids:
|
|
# Check if these are IDs (UUIDs) or names
|
|
if isinstance(tag_ids, list) and len(tag_ids) > 0:
|
|
# Assume IDs if first item looks like UUID (has hyphens and is long)
|
|
first_item = str(tag_ids[0])
|
|
if '-' in first_item and len(first_item) > 30:
|
|
tag_ids_to_use = tag_ids
|
|
elif lookup_tags_func:
|
|
# These are names, need to lookup/create
|
|
extra_headers = {}
|
|
if 'wix-site-id' in headers:
|
|
extra_headers['wix-site-id'] = headers['wix-site-id']
|
|
tag_ids_to_use = lookup_tags_func(
|
|
access_token, tag_ids, extra_headers if extra_headers else None
|
|
)
|
|
|
|
# Add categories if we have IDs (must be non-empty list of strings)
|
|
# CRITICAL: Wix API rejects empty arrays or arrays with None/empty strings
|
|
if category_ids_to_use and isinstance(category_ids_to_use, list) and len(category_ids_to_use) > 0:
|
|
# Filter out None, empty strings, and ensure all are valid UUID strings
|
|
valid_category_ids = [str(cid).strip() for cid in category_ids_to_use if cid and str(cid).strip()]
|
|
if valid_category_ids:
|
|
blog_data['draftPost']['categoryIds'] = valid_category_ids
|
|
logger.debug(f"Added {len(valid_category_ids)} category IDs")
|
|
else:
|
|
logger.warning("All category IDs were invalid, not including categoryIds in payload")
|
|
|
|
# Add tags if we have IDs (must be non-empty list of strings)
|
|
# CRITICAL: Wix API rejects empty arrays or arrays with None/empty strings
|
|
if tag_ids_to_use and isinstance(tag_ids_to_use, list) and len(tag_ids_to_use) > 0:
|
|
# Filter out None, empty strings, and ensure all are valid UUID strings
|
|
valid_tag_ids = [str(tid).strip() for tid in tag_ids_to_use if tid and str(tid).strip()]
|
|
if valid_tag_ids:
|
|
blog_data['draftPost']['tagIds'] = valid_tag_ids
|
|
logger.debug(f"Added {len(valid_tag_ids)} tag IDs")
|
|
else:
|
|
logger.warning("All tag IDs were invalid, not including tagIds in payload")
|
|
|
|
# Build SEO data from metadata if provided
|
|
# NOTE: seoData is optional - if it causes issues, we can create post without it
|
|
if seo_metadata:
|
|
try:
|
|
seo_data = build_seo_data(seo_metadata, title)
|
|
if seo_data:
|
|
tags_count = len(seo_data.get('tags', []))
|
|
keywords_count = len(seo_data.get('settings', {}).get('keywords', []))
|
|
wix_logger.log_seo_data(tags_count, keywords_count)
|
|
blog_data['draftPost']['seoData'] = seo_data
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ Wix: SEO data build failed - {str(e)[:50]}")
|
|
else:
|
|
logger.debug("No SEO metadata provided to create_blog_post")
|
|
|
|
try:
|
|
# Extract wix-site-id from token, parameter, or env var
|
|
extra_headers = {}
|
|
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.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")
|
|
|
|
if 'richContent' in draft_post:
|
|
rc = draft_post['richContent']
|
|
if not isinstance(rc, dict):
|
|
raise ValueError(f"richContent must be a dict, got {type(rc)}")
|
|
if 'nodes' not in rc:
|
|
raise ValueError("richContent missing 'nodes' field")
|
|
if not isinstance(rc['nodes'], list):
|
|
raise ValueError(f"richContent.nodes must be a list, got {type(rc['nodes'])}")
|
|
logger.debug(f"✅ richContent validation passed: {len(rc.get('nodes', []))} nodes")
|
|
|
|
if 'seoData' in draft_post:
|
|
seo = draft_post['seoData']
|
|
if not isinstance(seo, dict):
|
|
raise ValueError(f"seoData must be a dict, got {type(seo)}")
|
|
if 'tags' in seo and not isinstance(seo['tags'], list):
|
|
raise ValueError(f"seoData.tags must be a list, got {type(seo.get('tags'))}")
|
|
if 'settings' in seo and not isinstance(seo['settings'], dict):
|
|
raise ValueError(f"seoData.settings must be a dict, got {type(seo.get('settings'))}")
|
|
logger.debug(f"✅ seoData validation passed: {len(seo.get('tags', []))} tags")
|
|
|
|
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
|
|
|
|
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}")
|
|
|
|
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}")
|
|
|
|
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)
|
|
|
|
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
|
|
logger.error(f"TypeError in create_blog_post: {e}")
|
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
|
raise
|
|
except requests.RequestException as e:
|
|
logger.error(f"Failed to create blog post: {e}")
|
|
if hasattr(e, 'response') and e.response is not None:
|
|
logger.error(f"Response body: {e.response.text}")
|
|
raise
|
|
|