chore: push all remaining changes
- Blog writer enhancements and bug fixes - Wix integration improvements - Frontend UI updates - GSC dashboard docs cleanup - Image studio assets - LinkedIn requirements file - Various dependency updates
This commit is contained in:
@@ -66,19 +66,20 @@ class WixAuthService:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_site_info(self, access_token: str, meta_site_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
def get_site_info(self, access_token: str) -> Dict[str, Any]:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if self.client_id:
|
||||
headers['wix-client-id'] = self.client_id
|
||||
if meta_site_id:
|
||||
headers['wix-site-id'] = meta_site_id
|
||||
response = requests.get(f"{self.base_url}/sites/v1/site", headers=headers)
|
||||
if response.status_code == 404:
|
||||
logger.warning("Wix site info not found (404) — user may not have a published site or token lacks sites scope")
|
||||
return {"_no_site": True, "error": "No Wix site found for this account"}
|
||||
if response.status_code == 401:
|
||||
logger.warning("Wix site info request unauthorized (401) — token expired or invalid")
|
||||
return {"_auth_failed": True, "error": "Token expired or invalid — reconnect required"}
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import requests
|
||||
from loguru import logger
|
||||
|
||||
from .retry import wix_api_call_with_retry, WixAPIError
|
||||
from .auth_utils import get_wix_headers
|
||||
|
||||
|
||||
class WixBlogService:
|
||||
@@ -14,40 +15,7 @@ class WixBlogService:
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
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:
|
||||
if token.startswith('OauthNG.JWS.'):
|
||||
h['Authorization'] = f'Bearer {token}'
|
||||
logger.debug("Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
|
||||
elif token.startswith('IST.'):
|
||||
h['Authorization'] = token
|
||||
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 (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
|
||||
if extra:
|
||||
h.update(extra)
|
||||
return h
|
||||
return get_wix_headers(access_token, client_id=self.client_id, extra=extra)
|
||||
|
||||
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 retry logic and consolidated logging."""
|
||||
@@ -144,9 +112,9 @@ class WixBlogService:
|
||||
"""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']}
|
||||
payload: Dict[str, Any] = {'tag': {'label': label}, 'fieldsets': ['URL']}
|
||||
if language:
|
||||
payload['language'] = language
|
||||
payload['tag']['language'] = language
|
||||
|
||||
try:
|
||||
return wix_api_call_with_retry('POST', url, headers, json_payload=payload, max_attempts=3)
|
||||
|
||||
@@ -171,6 +171,16 @@ def validate_ricos_content(ricos_content: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return ricos_content
|
||||
|
||||
|
||||
_UUID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
|
||||
|
||||
def _looks_like_uuid(value: str) -> bool:
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
return True
|
||||
except (ValueError, AttributeError):
|
||||
return bool(_UUID_RE.match(value))
|
||||
|
||||
|
||||
def validate_payload_no_none(obj, path=""):
|
||||
"""Recursively validate that no None values exist in the payload"""
|
||||
if obj is None:
|
||||
@@ -224,6 +234,7 @@ def create_blog_post(
|
||||
"""
|
||||
# ===== PRE-FLIGHT VALIDATION =====
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
if not member_id:
|
||||
errors.append("memberId is required for third-party apps creating blog posts")
|
||||
@@ -279,6 +290,18 @@ def create_blog_post(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add wix-site-id to headers for all API calls (categories, tags, draft post)
|
||||
resolved_site_id = site_id or meta_site_id or os.getenv('WIX_SITE_ID')
|
||||
if resolved_site_id:
|
||||
headers['wix-site-id'] = resolved_site_id
|
||||
logger.info(f"Using wix-site-id: {resolved_site_id[:8]}... (source: {'param' if site_id else 'token' if meta_site_id else 'env'})")
|
||||
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.")
|
||||
else:
|
||||
logger.warning("No wix-site-id found — API calls may fail if token requires it")
|
||||
|
||||
# Quick permission test (only log failures)
|
||||
try:
|
||||
test_headers = get_wix_headers(access_token)
|
||||
@@ -296,14 +319,34 @@ def create_blog_post(
|
||||
|
||||
# Convert markdown to Ricos
|
||||
# PRIMARY: Use Wix Ricos Documents API for best formatting support (tables, complex markdown, etc.)
|
||||
# FALLBACK: Use custom parser if Wix API fails
|
||||
# FALLBACK: Use custom parser if Wix API fails (no length limit, handles tables natively)
|
||||
has_table = bool(re.search(r'^\|.*\|', content, re.MULTILINE))
|
||||
|
||||
# Pre-check: Wix Ricos API has a 10,000 character limit for HTML input.
|
||||
# Estimate HTML length from markdown (~1.4x expansion) to avoid silent truncation.
|
||||
# If HTML would exceed limit, skip Wix API and use custom parser.
|
||||
use_wix_api = True
|
||||
MAX_HTML_LIMIT = 9800
|
||||
estimated_html_len = len(content) * 1.4
|
||||
if estimated_html_len > MAX_HTML_LIMIT:
|
||||
logger.warning(f"Content too long for Wix Ricos API (est. HTML: {estimated_html_len:.0f} > {MAX_HTML_LIMIT}) — using custom parser")
|
||||
use_wix_api = False
|
||||
|
||||
ricos_content = None
|
||||
try:
|
||||
logger.info("Converting markdown via Wix Ricos Documents API...")
|
||||
ricos_content = convert_via_wix_api(content, access_token, base_url)
|
||||
logger.info(f"Wix API conversion succeeded: {len(ricos_content.get('nodes', []))} nodes")
|
||||
except Exception as e:
|
||||
logger.warning(f"Wix API conversion failed, falling back to custom parser: {e}")
|
||||
if use_wix_api:
|
||||
try:
|
||||
logger.info("Converting markdown via Wix Ricos Documents API...")
|
||||
ricos_content = convert_via_wix_api(content, access_token, base_url)
|
||||
logger.info(f"Wix API conversion succeeded: {len(ricos_content.get('nodes', []))} nodes")
|
||||
except Exception as e:
|
||||
logger.warning(f"Wix API conversion failed, falling back to custom parser: {e}")
|
||||
|
||||
# If markdown had tables and Wix API didn't produce TABLE nodes, fall back to custom parser
|
||||
if has_table and ricos_content:
|
||||
node_types = [n.get('type', '') for n in ricos_content.get('nodes', [])]
|
||||
if 'TABLE' not in node_types:
|
||||
logger.info("Markdown had tables but Wix API produced no TABLE nodes — using custom parser for table support")
|
||||
ricos_content = None
|
||||
|
||||
if not ricos_content or not isinstance(ricos_content, dict) or 'nodes' not in ricos_content:
|
||||
logger.info("Using custom markdown parser for Ricos conversion")
|
||||
@@ -414,44 +457,50 @@ def create_blog_post(
|
||||
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.")
|
||||
warnings.append("Cover image could not be imported — post published without cover image.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Cover image import failed (non-fatal): {e}. Continuing without cover image.")
|
||||
warnings.append(f"Cover image import failed: {str(e)[:100]}")
|
||||
|
||||
# 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)
|
||||
# Use proper UUID detection instead of fragile heuristic
|
||||
first_item = str(category_ids[0])
|
||||
if '-' in first_item and len(first_item) > 30:
|
||||
if _looks_like_uuid(first_item):
|
||||
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']
|
||||
if resolved_site_id:
|
||||
extra_headers['wix-site-id'] = resolved_site_id
|
||||
category_ids_to_use = lookup_categories_func(
|
||||
access_token, category_ids, extra_headers if extra_headers else None
|
||||
)
|
||||
if not category_ids_to_use:
|
||||
warnings.append(f"Categories could not be created ({len(category_ids)} requested) — OAuth app may lack BLOG.CREATE-DRAFT scope.")
|
||||
|
||||
# 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)
|
||||
# Use proper UUID detection instead of fragile heuristic
|
||||
first_item = str(tag_ids[0])
|
||||
if '-' in first_item and len(first_item) > 30:
|
||||
if _looks_like_uuid(first_item):
|
||||
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']
|
||||
if resolved_site_id:
|
||||
extra_headers['wix-site-id'] = resolved_site_id
|
||||
tag_ids_to_use = lookup_tags_func(
|
||||
access_token, tag_ids, extra_headers if extra_headers else None
|
||||
)
|
||||
if not tag_ids_to_use:
|
||||
warnings.append(f"Tags could not be created ({len(tag_ids)} requested) — OAuth app may lack BLOG scope for tag management.")
|
||||
|
||||
# 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
|
||||
@@ -491,24 +540,12 @@ def create_blog_post(
|
||||
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')
|
||||
# Use wix-site-id already resolved earlier
|
||||
extra_headers_final = {}
|
||||
wix_site_id = resolved_site_id
|
||||
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")
|
||||
extra_headers_final['wix-site-id'] = wix_site_id
|
||||
logger.info(f"Using wix-site-id for draft post: {wix_site_id[:8]}...")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not extract wix-site-id from token: {e}")
|
||||
|
||||
@@ -564,13 +601,17 @@ def create_blog_post(
|
||||
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)
|
||||
result = blog_service.create_draft_post(access_token, blog_data, extra_headers_final 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}")
|
||||
|
||||
if warnings:
|
||||
result['_warnings'] = warnings
|
||||
logger.info(f"Publish completed with {len(warnings)} warnings: {'; '.join(warnings)}")
|
||||
|
||||
return result
|
||||
except TypeError as e:
|
||||
import traceback
|
||||
|
||||
@@ -192,6 +192,120 @@ def _make_horizontal_rule_node() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _parse_markdown_table(lines: List[str], start_idx: int) -> tuple:
|
||||
"""
|
||||
Parse a markdown table starting at start_idx.
|
||||
Returns (table_rows, alignments, next_idx) where table_rows is a list of lists of cell text,
|
||||
and alignments is a list of column alignments ('left', 'center', 'right', None).
|
||||
|
||||
Markdown tables look like:
|
||||
| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
|
||||
Alignment is detected from the separator row:
|
||||
|:--------|:--------:|--------:|
|
||||
"""
|
||||
rows = []
|
||||
alignments = None
|
||||
i = start_idx
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
if not line or '|' not in line:
|
||||
break
|
||||
|
||||
cells = [cell.strip() for cell in line.strip('|').split('|')]
|
||||
|
||||
# Detect separator row (contains only dashes, colons, pipes, spaces)
|
||||
if i > start_idx and all(
|
||||
set(cell.strip()) <= set('-:| ') for cell in cells
|
||||
):
|
||||
alignments = []
|
||||
for cell in cells:
|
||||
cell = cell.strip()
|
||||
if cell.startswith(':') and cell.endswith(':'):
|
||||
alignments.append('center')
|
||||
elif cell.endswith(':'):
|
||||
alignments.append('right')
|
||||
elif cell.startswith(':'):
|
||||
alignments.append('left')
|
||||
else:
|
||||
alignments.append(None)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
rows.append(cells)
|
||||
i += 1
|
||||
|
||||
return rows, alignments or [None] * (len(rows[0]) if rows else 1), i
|
||||
|
||||
|
||||
def _make_table_node(header_row: List[str], body_rows: List[List[str]], alignments: List) -> Dict[str, Any]:
|
||||
"""Create a Ricos TABLE node with header and body rows, with formatting."""
|
||||
table_rows = []
|
||||
|
||||
all_rows = [header_row] + body_rows
|
||||
for row_idx, row_cells in enumerate(all_rows):
|
||||
cell_nodes = []
|
||||
for col_idx, cell_text in enumerate(row_cells):
|
||||
text_nodes = parse_markdown_inline(cell_text)
|
||||
# Bold header row cells
|
||||
if row_idx == 0 and text_nodes:
|
||||
for node in text_nodes:
|
||||
if node.get('type') == 'TEXT':
|
||||
decs = node['textData'].get('decorations', [])
|
||||
if not any(d.get('type') == 'BOLD' for d in decs if isinstance(d, dict)):
|
||||
decs_copy = decs.copy()
|
||||
decs_copy.append({'type': 'BOLD'})
|
||||
node['textData']['decorations'] = decs_copy
|
||||
|
||||
paragraph_node = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': text_nodes if text_nodes else [{
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [],
|
||||
'textData': {'text': cell_text or ' ', 'decorations': []}
|
||||
}],
|
||||
}
|
||||
|
||||
cell_style = {'verticalAlign': 'top'}
|
||||
if row_idx == 0:
|
||||
cell_style['borderWidth'] = {'top': 2, 'bottom': 1, 'left': 1, 'right': 1}
|
||||
# Apply column alignment
|
||||
if alignments and col_idx < len(alignments) and alignments[col_idx]:
|
||||
cell_style['textAlign'] = alignments[col_idx]
|
||||
|
||||
cell_node = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TABLE_CELL',
|
||||
'nodes': [paragraph_node],
|
||||
'tableCellData': {'style': cell_style},
|
||||
}
|
||||
cell_nodes.append(cell_node)
|
||||
|
||||
row_node = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TABLE_ROW',
|
||||
'nodes': cell_nodes,
|
||||
}
|
||||
table_rows.append(row_node)
|
||||
|
||||
num_cols = max(len(row) for row in all_rows) if all_rows else 1
|
||||
return {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TABLE',
|
||||
'nodes': table_rows,
|
||||
'tableData': {
|
||||
'cols': num_cols,
|
||||
'rows': len(table_rows),
|
||||
'headerRow': 0 if header_row else -1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert markdown content into valid Ricos JSON format.
|
||||
@@ -205,6 +319,7 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
- Code blocks (```language ... ```)
|
||||
- Inline images ()
|
||||
- Horizontal rules (---, ***, ___)
|
||||
- Tables (| Header | Header |)
|
||||
"""
|
||||
if not content:
|
||||
content = "This is a post from ALwrity."
|
||||
@@ -245,6 +360,16 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Markdown tables (lines starting with |)
|
||||
if stripped.startswith('|') and i + 1 < len(lines) and '|' in lines[i + 1]:
|
||||
table_rows, alignments, next_idx = _parse_markdown_table(lines, i)
|
||||
if table_rows and len(table_rows) >= 1:
|
||||
header_row = table_rows[0]
|
||||
body_rows = table_rows[1:] if len(table_rows) > 1 else []
|
||||
nodes.append(_make_table_node(header_row, body_rows, alignments))
|
||||
i = next_idx
|
||||
continue
|
||||
|
||||
# Headings
|
||||
if stripped.startswith('#'):
|
||||
level = len(stripped) - len(stripped.lstrip('#'))
|
||||
@@ -280,12 +405,11 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
})
|
||||
continue
|
||||
|
||||
# Unordered lists
|
||||
# Unordered lists (including task 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 stripped.startswith('-') else '* '
|
||||
|
||||
while i < len(lines):
|
||||
current_line = lines[i].strip()
|
||||
@@ -323,7 +447,14 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
|
||||
list_node_items = []
|
||||
for item_text in list_items:
|
||||
text_nodes = parse_markdown_inline(item_text)
|
||||
# Detect task list items: "- [ ] task" or "- [x] task"
|
||||
task_match = re.match(r'^\[([ xX])\]\s*(.*)', item_text)
|
||||
if task_match:
|
||||
checked = task_match.group(1).lower() == 'x'
|
||||
prefix = '☑ ' if checked else '☐ '
|
||||
text_nodes = parse_markdown_inline(prefix + task_match.group(2))
|
||||
else:
|
||||
text_nodes = parse_markdown_inline(item_text)
|
||||
paragraph_node = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
@@ -414,6 +545,7 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
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
|
||||
|
||||
@@ -75,7 +75,10 @@ class WixLogger:
|
||||
logger.debug(f" Payload: {', '.join(parts)}")
|
||||
|
||||
if error_body and status_code >= 400:
|
||||
error_msg = error_body.get('message', 'Unknown error')
|
||||
if isinstance(error_body, dict):
|
||||
error_msg = error_body.get('message', 'Unknown error')
|
||||
else:
|
||||
error_msg = str(error_body)
|
||||
logger.error(f" Error: {error_msg}")
|
||||
if status_code == 500:
|
||||
logger.error(" ⚠️ Internal server error - check Wix API status")
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
from loguru import logger
|
||||
|
||||
from .retry import wix_api_call_with_retry, WixAPIError
|
||||
|
||||
|
||||
def _is_valid_image_url(url: str) -> bool:
|
||||
"""Check if a URL looks like a valid, publicly accessible image URL for Wix import."""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
url = url.strip()
|
||||
if url.startswith('data:'):
|
||||
return False
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return False
|
||||
host = parsed.hostname or ''
|
||||
if host in ('localhost', '127.0.0.1', 'example.com') or host.endswith('.example.com'):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
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) -> Optional[Dict[str, Any]]:
|
||||
def import_image(self, access_token: str, image_url: str, display_name: str,
|
||||
client_id: Optional[str] = None, site_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Import external image to Wix Media Manager.
|
||||
|
||||
@@ -22,6 +40,8 @@ class WixMediaService:
|
||||
access_token: Valid access token
|
||||
image_url: URL of the image to import
|
||||
display_name: Display name for the image
|
||||
client_id: Optional Wix client ID for wix-client-id header
|
||||
site_id: Optional Wix metaSiteId for wix-site-id header
|
||||
|
||||
Returns:
|
||||
Media result dict with 'file' key, or None on failure
|
||||
@@ -29,10 +49,23 @@ class WixMediaService:
|
||||
Raises:
|
||||
WixAPIError: On non-retryable failure or after retries exhausted
|
||||
"""
|
||||
if not _is_valid_image_url(image_url):
|
||||
logger.warning(f"Skipping image import — URL not valid for Wix: {image_url[:80]}...")
|
||||
return None
|
||||
|
||||
logger.info(f"Importing image to Wix: url={image_url[:80]}..., display_name={display_name}")
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if client_id:
|
||||
headers['wix-client-id'] = client_id
|
||||
if not site_id:
|
||||
from .utils import extract_meta_from_token
|
||||
meta_info = extract_meta_from_token(access_token)
|
||||
site_id = meta_info.get('metaSiteId')
|
||||
if site_id:
|
||||
headers['wix-site-id'] = site_id
|
||||
payload = {
|
||||
'url': image_url,
|
||||
'mediaType': 'IMAGE',
|
||||
|
||||
@@ -26,10 +26,6 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
|
||||
Wix seoData object with settings.keywords and tags array, or None if empty
|
||||
"""
|
||||
seo_data = {
|
||||
'settings': {
|
||||
'keywords': [],
|
||||
'preventAutoRedirect': False # Required by Wix API schema
|
||||
},
|
||||
'tags': []
|
||||
}
|
||||
|
||||
@@ -77,11 +73,7 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
|
||||
# Keep main keyword + next 4 most important
|
||||
keywords_list = keywords_list[:5]
|
||||
|
||||
seo_data['settings']['keywords'] = keywords_list
|
||||
|
||||
# Validate keywords list is not empty (or ensure at least one keyword exists)
|
||||
if not seo_data['settings']['keywords']:
|
||||
logger.warning("No keywords found in SEO metadata, adding empty keywords array")
|
||||
seo_data['settings'] = {'keywords': keywords_list}
|
||||
|
||||
# Build tags array (meta tags, Open Graph, etc.)
|
||||
tags_list = []
|
||||
|
||||
Reference in New Issue
Block a user