@miravia_test/miravia-publish-skill 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SKILL.md +23 -9
- package/dist/TECHNICAL.md +1 -1
- package/dist/VERSION +1 -1
- package/dist/reference_publish.md +1 -1
- package/dist/scripts/cli_common.py +2 -2
- package/dist/scripts/{modules/extraction_engine.py → extraction_engine.py} +15 -6
- package/dist/scripts/{modules/file_reader.py → file_reader.py} +84 -16
- package/dist/scripts/i18n.py +158 -5
- package/dist/scripts/miravia_client.py +1 -0
- package/dist/scripts/product_client.py +25 -0
- package/dist/scripts/product_skill.py +258 -8
- package/dist/scripts/{modules/publish_module.py → publish_module.py} +1121 -126
- package/package.json +1 -1
- package/dist/scripts/modules/__init__.py +0 -1
|
@@ -29,7 +29,7 @@ import telemetry
|
|
|
29
29
|
from miravia_client import MiraviaClient, MiraviaError
|
|
30
30
|
from product_client import ProductClient, _normalize_variation_name
|
|
31
31
|
|
|
32
|
-
from
|
|
32
|
+
from extraction_engine import (
|
|
33
33
|
CRITICAL_FIELDS,
|
|
34
34
|
ExtractionEngine,
|
|
35
35
|
LLMClient,
|
|
@@ -37,7 +37,7 @@ from modules.extraction_engine import (
|
|
|
37
37
|
check_missing,
|
|
38
38
|
validate_title,
|
|
39
39
|
)
|
|
40
|
-
from
|
|
40
|
+
from file_reader import extract_text, read_sources, get_excel_row_count
|
|
41
41
|
|
|
42
42
|
logger = logging.getLogger("miravia.product.publish")
|
|
43
43
|
|
|
@@ -67,6 +67,11 @@ _MAX_DIMENSION_CM = 300
|
|
|
67
67
|
_MAX_IMAGE_SIZE_MB = 5
|
|
68
68
|
_VALID_IMAGE_FORMATS = {".jpg", ".jpeg", ".png", ".webp"}
|
|
69
69
|
|
|
70
|
+
# DBM (Drop by Miravia) limits — packages exceeding these auto-switch to DBS
|
|
71
|
+
_DBM_MAX_WEIGHT_KG = 40
|
|
72
|
+
_DBM_MAX_DIM_SUM_CM = 300
|
|
73
|
+
_DBM_MAX_VOLUME_WEIGHT = 40 # L*W*H/6000
|
|
74
|
+
|
|
70
75
|
|
|
71
76
|
_BRAND_PERMISSION_CODES = frozenset(("BRAND_PENDING", "BRAND_PERMISSION_DENIED"))
|
|
72
77
|
|
|
@@ -273,6 +278,10 @@ class PublishModule:
|
|
|
273
278
|
if skus:
|
|
274
279
|
product_payload["Skus"] = {"Sku": skus}
|
|
275
280
|
|
|
281
|
+
# Draft mode — set Status to inactive
|
|
282
|
+
if draft_requested:
|
|
283
|
+
product_payload["Status"] = "inactive"
|
|
284
|
+
|
|
276
285
|
# Inject safety warning from CLI flags into Attributes
|
|
277
286
|
safety_warning_arg = getattr(self.args, "safety_warning", None)
|
|
278
287
|
if safety_warning_arg is not None:
|
|
@@ -398,7 +407,27 @@ class PublishModule:
|
|
|
398
407
|
for m in gpsr_missing:
|
|
399
408
|
draft_issues.append(f"NOTE: {m}")
|
|
400
409
|
|
|
401
|
-
#
|
|
410
|
+
# Stage 3b: unified compliance check (Safety Warning + compliance docs)
|
|
411
|
+
compliance_check = self._check_compliance_fields(
|
|
412
|
+
product_payload,
|
|
413
|
+
category_id=category_id,
|
|
414
|
+
draft_requested=draft_requested,
|
|
415
|
+
draft_issues=draft_issues,
|
|
416
|
+
)
|
|
417
|
+
if compliance_check:
|
|
418
|
+
return compliance_check
|
|
419
|
+
|
|
420
|
+
# Stage 3c: special category rules (PPU / ISBN / food attributes)
|
|
421
|
+
special_check = self._check_special_category_rules(
|
|
422
|
+
product_payload,
|
|
423
|
+
category_id=category_id,
|
|
424
|
+
draft_requested=draft_requested,
|
|
425
|
+
draft_issues=draft_issues,
|
|
426
|
+
)
|
|
427
|
+
if special_check:
|
|
428
|
+
return special_check
|
|
429
|
+
|
|
430
|
+
# Check postToInactive fields (EAN only; PPU/food moved to Stage 3c)
|
|
402
431
|
post_to_inactive_result = self._check_post_to_inactive_fields(
|
|
403
432
|
product_payload, str(category_id), draft_requested, draft_issues
|
|
404
433
|
)
|
|
@@ -450,37 +479,6 @@ class PublishModule:
|
|
|
450
479
|
"Please fill it or publish to Draft."
|
|
451
480
|
)
|
|
452
481
|
|
|
453
|
-
# Check compliance_documents
|
|
454
|
-
compliance_docs = getattr(self.args, "compliance_documents", None) or []
|
|
455
|
-
compliance_mandatory = self._is_category_attribute_mandatory(
|
|
456
|
-
definitions, "compliance_documents"
|
|
457
|
-
)
|
|
458
|
-
if compliance_mandatory and not compliance_docs:
|
|
459
|
-
post_to_inactive_missing.append(
|
|
460
|
-
"Compliance documents are required for this category but not filled. "
|
|
461
|
-
"Please fill them or publish to Draft."
|
|
462
|
-
)
|
|
463
|
-
|
|
464
|
-
# Check unit_count - mandatory for nail polish category
|
|
465
|
-
unit_count = getattr(self.args, "unit_count", None)
|
|
466
|
-
unit_count_mandatory = self._is_category_attribute_mandatory(
|
|
467
|
-
definitions, "unit_count"
|
|
468
|
-
)
|
|
469
|
-
if unit_count_mandatory and not unit_count:
|
|
470
|
-
post_to_inactive_missing.append(
|
|
471
|
-
"Unit count is required for this category but not filled. "
|
|
472
|
-
"Please fill it or publish to Draft."
|
|
473
|
-
)
|
|
474
|
-
|
|
475
|
-
# Check food_attributes - mandatory for food category
|
|
476
|
-
food_attrs = getattr(self.args, "sku_attributes_food_health", None) or {}
|
|
477
|
-
food_mandatory = self._category_looks_like_food_health(definitions)
|
|
478
|
-
if food_mandatory and not food_attrs:
|
|
479
|
-
post_to_inactive_missing.append(
|
|
480
|
-
"Food & health attributes are required for this category but not filled. "
|
|
481
|
-
"Please fill them or publish to Draft."
|
|
482
|
-
)
|
|
483
|
-
|
|
484
482
|
# Handle postToInactive missing fields
|
|
485
483
|
if post_to_inactive_missing:
|
|
486
484
|
if not draft_requested:
|
|
@@ -499,6 +497,354 @@ class PublishModule:
|
|
|
499
497
|
|
|
500
498
|
return None
|
|
501
499
|
|
|
500
|
+
def _check_compliance_fields(
|
|
501
|
+
self,
|
|
502
|
+
product_payload: Dict[str, Any],
|
|
503
|
+
category_attributes: Optional[List[Dict[str, Any]]] = None,
|
|
504
|
+
*,
|
|
505
|
+
category_id: Optional[Any] = None,
|
|
506
|
+
schema: Optional[Any] = None,
|
|
507
|
+
draft_requested: bool = False,
|
|
508
|
+
draft_issues: Optional[List[str]] = None,
|
|
509
|
+
) -> Optional[Dict[str, Any]]:
|
|
510
|
+
"""Unified compliance validation after payload is built.
|
|
511
|
+
|
|
512
|
+
Checks:
|
|
513
|
+
1. GPSR (manufacturer + EU responsible) — for AI flow
|
|
514
|
+
2. Safety Warning linkage (Yes → content required)
|
|
515
|
+
3. Compliance documents (category-mandatory → required)
|
|
516
|
+
|
|
517
|
+
Returns a ``compliance_missing_suggest_draft`` response when any
|
|
518
|
+
compliance field is missing, or ``None`` when all are satisfied.
|
|
519
|
+
"""
|
|
520
|
+
compliance_issues: List[str] = []
|
|
521
|
+
attributes = product_payload.get("Attributes") or {}
|
|
522
|
+
|
|
523
|
+
# Fetch category attributes if not provided (manual flow)
|
|
524
|
+
if category_attributes is None and category_id is not None:
|
|
525
|
+
lang = self.args.lang if self.args.lang not in (None, "auto") else "en_US"
|
|
526
|
+
try:
|
|
527
|
+
category_attributes = self.product.get_category_attributes(
|
|
528
|
+
str(category_id), language_code=lang
|
|
529
|
+
)
|
|
530
|
+
except Exception:
|
|
531
|
+
category_attributes = []
|
|
532
|
+
if category_attributes is None:
|
|
533
|
+
category_attributes = []
|
|
534
|
+
|
|
535
|
+
# --- 1. GPSR check (mainly for AI flow; manual flow has inline check) ---
|
|
536
|
+
mfr_name = ""
|
|
537
|
+
eurp_name = ""
|
|
538
|
+
if schema:
|
|
539
|
+
mfr_name = schema.manufacturer_name or ""
|
|
540
|
+
eurp_name = schema.eu_responsible_person_name or ""
|
|
541
|
+
else:
|
|
542
|
+
mfr_name = getattr(self.args, "manufacturer_name", None) or ""
|
|
543
|
+
eurp_name = getattr(self.args, "eu_responsible_person_name", None) or ""
|
|
544
|
+
|
|
545
|
+
if not mfr_name and not eurp_name:
|
|
546
|
+
compliance_issues.append(
|
|
547
|
+
i18n._("pub_compliance_manufacturer_eurp_both_empty")
|
|
548
|
+
)
|
|
549
|
+
elif mfr_name and not eurp_name:
|
|
550
|
+
compliance_issues.append(
|
|
551
|
+
i18n._("pub_compliance_eurp_missing_with_manufacturer")
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# --- 2. Safety Warning linkage ---
|
|
555
|
+
safety_val = (
|
|
556
|
+
attributes.get("Does this product have a safety warning")
|
|
557
|
+
or attributes.get("Does_this_product_have_a_safety_warning")
|
|
558
|
+
or ""
|
|
559
|
+
)
|
|
560
|
+
if safety_val == "Yes":
|
|
561
|
+
safety_content = attributes.get("Safety_warning_content") or ""
|
|
562
|
+
if not safety_content.strip():
|
|
563
|
+
compliance_issues.append(
|
|
564
|
+
i18n._("pub_safety_warning_content_missing")
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# --- 3. Compliance documents (category-mandatory) ---
|
|
568
|
+
compliance_docs = None
|
|
569
|
+
if schema:
|
|
570
|
+
compliance_docs = schema.compliance_documents or None
|
|
571
|
+
else:
|
|
572
|
+
compliance_docs = getattr(self.args, "compliance_documents", None) or None
|
|
573
|
+
|
|
574
|
+
compliance_mandatory = self._is_category_attribute_mandatory(
|
|
575
|
+
category_attributes, "compliance_documents"
|
|
576
|
+
)
|
|
577
|
+
if compliance_mandatory and not compliance_docs:
|
|
578
|
+
compliance_issues.append(
|
|
579
|
+
i18n._("pub_compliance_docs_missing")
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if not compliance_issues:
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
if not draft_requested:
|
|
586
|
+
return {
|
|
587
|
+
"status": "compliance_missing_suggest_draft",
|
|
588
|
+
"message": i18n._("pub_compliance_missing_message"),
|
|
589
|
+
"compliance_issues": compliance_issues,
|
|
590
|
+
"next_actions": [
|
|
591
|
+
i18n._("pub_compliance_fill_or_draft"),
|
|
592
|
+
i18n._("pub_draft_option_hint"),
|
|
593
|
+
],
|
|
594
|
+
}
|
|
595
|
+
else:
|
|
596
|
+
if draft_issues is not None:
|
|
597
|
+
for issue in compliance_issues:
|
|
598
|
+
draft_issues.append(f"NOTE: {issue}")
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
def _check_special_category_rules(
|
|
602
|
+
self,
|
|
603
|
+
product_payload: Dict[str, Any],
|
|
604
|
+
category_attributes: Optional[List[Dict[str, Any]]] = None,
|
|
605
|
+
*,
|
|
606
|
+
category_id: Optional[Any] = None,
|
|
607
|
+
schema: Optional[Any] = None,
|
|
608
|
+
draft_requested: bool = False,
|
|
609
|
+
draft_issues: Optional[List[str]] = None,
|
|
610
|
+
) -> Optional[Dict[str, Any]]:
|
|
611
|
+
"""Validate special category rules: PPU, ISBN, food attributes.
|
|
612
|
+
|
|
613
|
+
Returns:
|
|
614
|
+
- ``special_category_block`` for hard errors (invalid PPU value, ISBN missing for books)
|
|
615
|
+
- ``special_category_suggest_draft`` for missing fields (PPU missing, food attrs missing)
|
|
616
|
+
- ``None`` when all rules are satisfied.
|
|
617
|
+
"""
|
|
618
|
+
attributes = product_payload.get("Attributes") or {}
|
|
619
|
+
|
|
620
|
+
# Fetch category attributes if not provided
|
|
621
|
+
if category_attributes is None and category_id is not None:
|
|
622
|
+
lang = self.args.lang if self.args.lang not in (None, "auto") else "en_US"
|
|
623
|
+
try:
|
|
624
|
+
category_attributes = self.product.get_category_attributes(
|
|
625
|
+
str(category_id), language_code=lang
|
|
626
|
+
)
|
|
627
|
+
except Exception:
|
|
628
|
+
category_attributes = []
|
|
629
|
+
if category_attributes is None:
|
|
630
|
+
category_attributes = []
|
|
631
|
+
|
|
632
|
+
# --- 1. PPU (Price Per Unit): standard_unit_type + unit_count ---
|
|
633
|
+
ppu_missing: List[str] = []
|
|
634
|
+
ppu_invalid: List[str] = []
|
|
635
|
+
|
|
636
|
+
# Get values from schema or CLI args
|
|
637
|
+
if schema:
|
|
638
|
+
unit_type = schema.standard_unit_type or ""
|
|
639
|
+
unit_count_val = schema.unit_count
|
|
640
|
+
else:
|
|
641
|
+
unit_type = getattr(self.args, "standard_unit_type", None) or getattr(
|
|
642
|
+
self.args, "unit_count_type", None
|
|
643
|
+
) or ""
|
|
644
|
+
unit_count_val = getattr(self.args, "unit_count", None)
|
|
645
|
+
|
|
646
|
+
# Also check payload attributes as fallback
|
|
647
|
+
if not unit_type:
|
|
648
|
+
unit_type = (
|
|
649
|
+
attributes.get("unit_count_type")
|
|
650
|
+
or attributes.get("standard_unit_type")
|
|
651
|
+
or ""
|
|
652
|
+
)
|
|
653
|
+
if unit_count_val is None:
|
|
654
|
+
unit_count_val = attributes.get("unit_count")
|
|
655
|
+
|
|
656
|
+
unit_count_str = str(unit_count_val).strip() if unit_count_val is not None else ""
|
|
657
|
+
|
|
658
|
+
# Check if PPU is category-mandatory
|
|
659
|
+
ppu_mandatory = (
|
|
660
|
+
self._is_category_attribute_mandatory(category_attributes, "unit_count")
|
|
661
|
+
or self._is_category_attribute_mandatory(
|
|
662
|
+
category_attributes, "unit_count_type"
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
if ppu_mandatory or unit_type or unit_count_str:
|
|
667
|
+
# Check missing
|
|
668
|
+
if not unit_type and not unit_count_str:
|
|
669
|
+
ppu_missing.append(
|
|
670
|
+
i18n._("pub_ppu_both_missing")
|
|
671
|
+
)
|
|
672
|
+
elif not unit_type:
|
|
673
|
+
ppu_missing.append(
|
|
674
|
+
i18n._("pub_ppu_type_missing")
|
|
675
|
+
)
|
|
676
|
+
elif not unit_count_str:
|
|
677
|
+
ppu_missing.append(
|
|
678
|
+
i18n._("pub_ppu_count_missing")
|
|
679
|
+
)
|
|
680
|
+
else:
|
|
681
|
+
# Both present — validate unit_count value
|
|
682
|
+
try:
|
|
683
|
+
uc = float(unit_count_str)
|
|
684
|
+
if uc <= 0:
|
|
685
|
+
ppu_invalid.append(
|
|
686
|
+
i18n._("pub_ppu_count_invalid", value=unit_count_str)
|
|
687
|
+
)
|
|
688
|
+
except (TypeError, ValueError):
|
|
689
|
+
ppu_invalid.append(
|
|
690
|
+
i18n._("pub_ppu_count_not_number", value=unit_count_str)
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
# --- 2. ISBN for book categories ---
|
|
694
|
+
isbn_missing = False
|
|
695
|
+
isbn_mandatory = self._is_category_attribute_mandatory(
|
|
696
|
+
category_attributes, "isbn"
|
|
697
|
+
)
|
|
698
|
+
if isbn_mandatory:
|
|
699
|
+
isbn_val = None
|
|
700
|
+
if schema:
|
|
701
|
+
isbn_val = schema.isbn
|
|
702
|
+
else:
|
|
703
|
+
isbn_val = getattr(self.args, "isbn", None)
|
|
704
|
+
if not isbn_val:
|
|
705
|
+
# Also check payload attributes
|
|
706
|
+
isbn_val = attributes.get("isbn")
|
|
707
|
+
if not isbn_val:
|
|
708
|
+
isbn_missing = True
|
|
709
|
+
|
|
710
|
+
# --- 3. Food & health attributes ---
|
|
711
|
+
food_missing = False
|
|
712
|
+
food_mandatory = self._category_looks_like_food_health(category_attributes)
|
|
713
|
+
if food_mandatory:
|
|
714
|
+
food_val = None
|
|
715
|
+
if schema:
|
|
716
|
+
food_val = schema.sku_attributes_food_health
|
|
717
|
+
else:
|
|
718
|
+
food_val = getattr(self.args, "sku_attributes_food_health", None) or None
|
|
719
|
+
if not food_val:
|
|
720
|
+
food_missing = True
|
|
721
|
+
|
|
722
|
+
# --- Build response ---
|
|
723
|
+
# Hard blocks first (invalid values, ISBN missing for books)
|
|
724
|
+
if ppu_invalid:
|
|
725
|
+
return {
|
|
726
|
+
"status": "special_category_block",
|
|
727
|
+
"message": i18n._("pub_ppu_validation_failed"),
|
|
728
|
+
"block_issues": ppu_invalid,
|
|
729
|
+
"next_actions": [i18n._("pub_ppu_fix_and_rerun")],
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if isbn_missing:
|
|
733
|
+
return {
|
|
734
|
+
"status": "special_category_block",
|
|
735
|
+
"message": i18n._("pub_isbn_required_for_books"),
|
|
736
|
+
"block_issues": [i18n._("pub_isbn_missing")],
|
|
737
|
+
"next_actions": [i18n._("pub_isbn_fix_and_rerun")],
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
# Draft suggestions (missing fields)
|
|
741
|
+
draft_issues_list: List[str] = []
|
|
742
|
+
if ppu_missing:
|
|
743
|
+
draft_issues_list.extend(ppu_missing)
|
|
744
|
+
if food_missing:
|
|
745
|
+
draft_issues_list.append(i18n._("pub_food_attrs_missing"))
|
|
746
|
+
|
|
747
|
+
if not draft_issues_list:
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
if not draft_requested:
|
|
751
|
+
return {
|
|
752
|
+
"status": "special_category_suggest_draft",
|
|
753
|
+
"message": i18n._("pub_special_category_missing_message"),
|
|
754
|
+
"missing_fields": draft_issues_list,
|
|
755
|
+
"next_actions": [
|
|
756
|
+
i18n._("pub_special_category_fill_or_draft"),
|
|
757
|
+
i18n._("pub_draft_option_hint"),
|
|
758
|
+
],
|
|
759
|
+
}
|
|
760
|
+
else:
|
|
761
|
+
if draft_issues is not None:
|
|
762
|
+
for issue in draft_issues_list:
|
|
763
|
+
draft_issues.append(f"NOTE: {issue}")
|
|
764
|
+
return None
|
|
765
|
+
|
|
766
|
+
def _check_dbm_limits(
|
|
767
|
+
self,
|
|
768
|
+
product_payload: Dict[str, Any],
|
|
769
|
+
*,
|
|
770
|
+
has_dbs_permission: bool = False,
|
|
771
|
+
) -> Optional[Dict[str, Any]]:
|
|
772
|
+
"""Check if package exceeds DBM limits and auto-switch to DBS.
|
|
773
|
+
|
|
774
|
+
DBM limits:
|
|
775
|
+
- Weight <= 40KG
|
|
776
|
+
- L+W+H <= 300cm
|
|
777
|
+
- Volumetric weight (L*W*H/6000) <= 40
|
|
778
|
+
|
|
779
|
+
If any limit is exceeded and delivery_method is DBM:
|
|
780
|
+
- Seller has DBS permission → auto-switch to DBS
|
|
781
|
+
- Seller has no DBS permission → return error (block)
|
|
782
|
+
Returns a dict with result, None if within limits.
|
|
783
|
+
"""
|
|
784
|
+
attrs = product_payload.get("Attributes") or {}
|
|
785
|
+
delivery_method = attrs.get("delivery_method") or attrs.get("Delivery_method") or ""
|
|
786
|
+
|
|
787
|
+
# Only check if delivery method is DBM
|
|
788
|
+
if delivery_method.upper() != "DBM":
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
skus = (product_payload.get("Skus") or {}).get("Sku") or []
|
|
792
|
+
if not skus:
|
|
793
|
+
return None
|
|
794
|
+
|
|
795
|
+
# Check first SKU (DBM/DBS is product-level, same for all SKUs)
|
|
796
|
+
sku = skus[0]
|
|
797
|
+
weight = float(sku.get("package_weight") or 0)
|
|
798
|
+
length = float(sku.get("package_length") or 0)
|
|
799
|
+
width = float(sku.get("package_width") or 0)
|
|
800
|
+
height = float(sku.get("package_height") or 0)
|
|
801
|
+
|
|
802
|
+
if not (weight and length and width and height):
|
|
803
|
+
return None
|
|
804
|
+
|
|
805
|
+
dim_sum = length + width + height
|
|
806
|
+
volume_weight = (length * width * height) / 6000
|
|
807
|
+
|
|
808
|
+
exceeded: List[str] = []
|
|
809
|
+
if weight > _DBM_MAX_WEIGHT_KG:
|
|
810
|
+
exceeded.append(
|
|
811
|
+
i18n._("pub_dbm_weight_exceeded", weight=f"{weight}kg", limit=_DBM_MAX_WEIGHT_KG)
|
|
812
|
+
)
|
|
813
|
+
if dim_sum > _DBM_MAX_DIM_SUM_CM:
|
|
814
|
+
exceeded.append(
|
|
815
|
+
i18n._("pub_dbm_dim_sum_exceeded", dim_sum=f"{dim_sum}cm", limit=_DBM_MAX_DIM_SUM_CM)
|
|
816
|
+
)
|
|
817
|
+
if volume_weight > _DBM_MAX_VOLUME_WEIGHT:
|
|
818
|
+
exceeded.append(
|
|
819
|
+
i18n._("pub_dbm_volume_exceeded", volume=f"{volume_weight:.1f}", limit=_DBM_MAX_VOLUME_WEIGHT)
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
if not exceeded:
|
|
823
|
+
return None
|
|
824
|
+
|
|
825
|
+
# Exceeded DBM limits — check DBS permission
|
|
826
|
+
if has_dbs_permission:
|
|
827
|
+
# Auto-switch to DBS
|
|
828
|
+
attrs["delivery_method"] = "DBS"
|
|
829
|
+
attrs["Delivery_method"] = "DBS"
|
|
830
|
+
# Also update SKU-level if present
|
|
831
|
+
for s in skus:
|
|
832
|
+
if s.get("delivery_method"):
|
|
833
|
+
s["delivery_method"] = "DBS"
|
|
834
|
+
return {
|
|
835
|
+
"switched_to_dbs": True,
|
|
836
|
+
"reasons": exceeded,
|
|
837
|
+
"message": i18n._("pub_dbm_auto_switch_to_dbs"),
|
|
838
|
+
}
|
|
839
|
+
else:
|
|
840
|
+
# No DBS permission — block with error
|
|
841
|
+
return {
|
|
842
|
+
"switched_to_dbs": False,
|
|
843
|
+
"blocked": True,
|
|
844
|
+
"reasons": exceeded,
|
|
845
|
+
"message": i18n._("pub_dbm_no_dbs_permission"),
|
|
846
|
+
}
|
|
847
|
+
|
|
502
848
|
def _finalize_create(
|
|
503
849
|
self,
|
|
504
850
|
product_payload: Dict[str, Any],
|
|
@@ -511,6 +857,26 @@ class PublishModule:
|
|
|
511
857
|
"""Validate, preview, pre-validate, and execute product creation."""
|
|
512
858
|
_validate_create_payload(product_payload)
|
|
513
859
|
|
|
860
|
+
# Check DBM limits — auto-switch to DBS if exceeded (requires DBS permission)
|
|
861
|
+
has_dbs = self.product.get_shipping_permission()
|
|
862
|
+
dbm_result = self._check_dbm_limits(product_payload, has_dbs_permission=has_dbs)
|
|
863
|
+
if dbm_result:
|
|
864
|
+
if dbm_result.get("blocked"):
|
|
865
|
+
# No DBS permission and exceeds DBM limits — block
|
|
866
|
+
return {
|
|
867
|
+
"status": "dbm_limit_exceeded_no_dbs",
|
|
868
|
+
"message": dbm_result["message"],
|
|
869
|
+
"reasons": dbm_result.get("reasons", []),
|
|
870
|
+
"next_actions": [
|
|
871
|
+
i18n._("pub_dbm_fix_or_apply_dbs"),
|
|
872
|
+
],
|
|
873
|
+
}
|
|
874
|
+
if dbm_result.get("switched_to_dbs"):
|
|
875
|
+
if draft_issues is not None:
|
|
876
|
+
draft_issues.append(dbm_result["message"])
|
|
877
|
+
for reason in dbm_result.get("reasons", []):
|
|
878
|
+
draft_issues.append(f" - {reason}")
|
|
879
|
+
|
|
514
880
|
# Run pre-validate early so errors are visible in the preview
|
|
515
881
|
prevalidate_result = self._run_prevalidate(product_payload)
|
|
516
882
|
prevalidate_errors = []
|
|
@@ -594,6 +960,17 @@ class PublishModule:
|
|
|
594
960
|
i18n._("pub_brand_permission_draft_suggestion", code=exc.code),
|
|
595
961
|
],
|
|
596
962
|
}
|
|
963
|
+
# SKU already exists — concurrency conflict or duplicate SKU
|
|
964
|
+
if exc.code == "SKU_ALREADY_EXISTS":
|
|
965
|
+
return {
|
|
966
|
+
"status": "sku_already_exists",
|
|
967
|
+
"message": i18n._("pub_sku_already_exists"),
|
|
968
|
+
"code": exc.code,
|
|
969
|
+
"hint": exc.hint,
|
|
970
|
+
"next_actions": [
|
|
971
|
+
i18n._("pub_sku_already_exists_next"),
|
|
972
|
+
],
|
|
973
|
+
}
|
|
597
974
|
# Code 4234: draft product created but missing required fields
|
|
598
975
|
if exc.code == "4234" and draft:
|
|
599
976
|
err_data = (exc.payload.get("data") or {}) if exc.payload else {}
|
|
@@ -630,16 +1007,112 @@ class PublishModule:
|
|
|
630
1007
|
# AI-assisted create flow
|
|
631
1008
|
# ==================================================================
|
|
632
1009
|
|
|
1010
|
+
def _extract_product_summaries(
|
|
1011
|
+
self, content_items: List[Dict[str, Any]]
|
|
1012
|
+
) -> List[Dict[str, Any]]:
|
|
1013
|
+
"""Extract basic product info from each Excel row for display.
|
|
1014
|
+
|
|
1015
|
+
Returns a list of dicts with index, title, price, sku fields.
|
|
1016
|
+
"""
|
|
1017
|
+
summaries: List[Dict[str, Any]] = []
|
|
1018
|
+
idx = 1
|
|
1019
|
+
for item in content_items:
|
|
1020
|
+
if item.get("type") != "table":
|
|
1021
|
+
continue
|
|
1022
|
+
content = item.get("content")
|
|
1023
|
+
if not content:
|
|
1024
|
+
continue
|
|
1025
|
+
tables = content if isinstance(content, list) else [content]
|
|
1026
|
+
for table in tables:
|
|
1027
|
+
rows = []
|
|
1028
|
+
if isinstance(table, dict) and "rows" in table:
|
|
1029
|
+
rows = table.get("rows", [])
|
|
1030
|
+
elif isinstance(table, list):
|
|
1031
|
+
rows = table
|
|
1032
|
+
for row in rows:
|
|
1033
|
+
# Try to extract common product fields
|
|
1034
|
+
title = (
|
|
1035
|
+
row.get("product_title")
|
|
1036
|
+
or row.get("title")
|
|
1037
|
+
or row.get("name")
|
|
1038
|
+
or row.get("product_name")
|
|
1039
|
+
or ""
|
|
1040
|
+
)
|
|
1041
|
+
price = (
|
|
1042
|
+
row.get("general_price")
|
|
1043
|
+
or row.get("price")
|
|
1044
|
+
or row.get("original_price")
|
|
1045
|
+
or ""
|
|
1046
|
+
)
|
|
1047
|
+
sku = (
|
|
1048
|
+
row.get("seller_sku")
|
|
1049
|
+
or row.get("SellerSku")
|
|
1050
|
+
or row.get("sku")
|
|
1051
|
+
or ""
|
|
1052
|
+
)
|
|
1053
|
+
summaries.append({
|
|
1054
|
+
"index": idx,
|
|
1055
|
+
"title": str(title) if title else "",
|
|
1056
|
+
"price": str(price) if price else "",
|
|
1057
|
+
"sku": str(sku) if sku else "",
|
|
1058
|
+
})
|
|
1059
|
+
idx += 1
|
|
1060
|
+
return summaries
|
|
1061
|
+
|
|
633
1062
|
def _run_create_from_source(self) -> Dict[str, Any]:
|
|
634
1063
|
"""AI posting: read files/URLs, extract fields, confirm, and publish."""
|
|
635
1064
|
sources = getattr(self.args, "source", []) or []
|
|
636
1065
|
if not sources:
|
|
637
1066
|
raise ValueError(i18n._("pub_ai_post_requires_source"))
|
|
638
1067
|
|
|
639
|
-
|
|
1068
|
+
try:
|
|
1069
|
+
content_items = read_sources(sources)
|
|
1070
|
+
except ValueError as exc:
|
|
1071
|
+
msg = str(exc)
|
|
1072
|
+
# Map file_reader ValueError messages to i18n status codes
|
|
1073
|
+
if "Unsupported file format" in msg:
|
|
1074
|
+
return {
|
|
1075
|
+
"status": "unsupported_format",
|
|
1076
|
+
"message": i18n._("pub_unsupported_format", source=sources[0]),
|
|
1077
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
1078
|
+
}
|
|
1079
|
+
if "empty" in msg.lower() or "corrupted" in msg.lower():
|
|
1080
|
+
return {
|
|
1081
|
+
"status": "file_empty_or_corrupted",
|
|
1082
|
+
"message": i18n._("pub_file_empty_or_corrupted", source=sources[0]),
|
|
1083
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
1084
|
+
}
|
|
1085
|
+
if "Failed to fetch URL" in msg or "fetch" in msg.lower():
|
|
1086
|
+
return {
|
|
1087
|
+
"status": "invalid_url",
|
|
1088
|
+
"message": i18n._("pub_url_fetch_failed", source=sources[0], error=msg),
|
|
1089
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
1090
|
+
}
|
|
1091
|
+
# Fallback: return the raw error message
|
|
1092
|
+
return {
|
|
1093
|
+
"status": "file_read_error",
|
|
1094
|
+
"message": msg,
|
|
1095
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
1096
|
+
}
|
|
640
1097
|
if not content_items:
|
|
641
1098
|
raise ValueError(i18n._("pub_ai_post_no_content"))
|
|
642
1099
|
|
|
1100
|
+
# Check for multiple products in Excel
|
|
1101
|
+
row_count = get_excel_row_count(content_items)
|
|
1102
|
+
if row_count > 1:
|
|
1103
|
+
# Extract basic info from each row for display
|
|
1104
|
+
product_summaries = self._extract_product_summaries(content_items)
|
|
1105
|
+
return {
|
|
1106
|
+
"status": "multiple_products_detected",
|
|
1107
|
+
"message": i18n._("pub_multiple_products_detected", count=row_count),
|
|
1108
|
+
"product_summaries": product_summaries,
|
|
1109
|
+
"next_actions": [
|
|
1110
|
+
i18n._("pub_multiple_products_select_index"),
|
|
1111
|
+
i18n._("pub_multiple_products_select_all"),
|
|
1112
|
+
i18n._("pub_multiple_products_cancel"),
|
|
1113
|
+
],
|
|
1114
|
+
}
|
|
1115
|
+
|
|
643
1116
|
source_text = extract_text(content_items)
|
|
644
1117
|
engine = ExtractionEngine(LLMClient())
|
|
645
1118
|
schema = engine.extract(source_text)
|
|
@@ -669,7 +1142,12 @@ class PublishModule:
|
|
|
669
1142
|
|
|
670
1143
|
title_err = validate_title(schema.product_title)
|
|
671
1144
|
if title_err:
|
|
672
|
-
|
|
1145
|
+
return {
|
|
1146
|
+
"status": "title_invalid",
|
|
1147
|
+
"message": title_err,
|
|
1148
|
+
"format_suggestion": i18n._("pub_title_format_suggestion"),
|
|
1149
|
+
"next_actions": [i18n._("pub_ai_post_requires_source")],
|
|
1150
|
+
}
|
|
673
1151
|
|
|
674
1152
|
ctb_preview = {
|
|
675
1153
|
"stage": "ctb",
|
|
@@ -702,7 +1180,7 @@ class PublishModule:
|
|
|
702
1180
|
if compliance_result.get("status"):
|
|
703
1181
|
return compliance_result
|
|
704
1182
|
|
|
705
|
-
images, failed_images = self._process_images_for_schema(schema)
|
|
1183
|
+
images, failed_images, image_warnings = self._process_images_for_schema(schema)
|
|
706
1184
|
if failed_images:
|
|
707
1185
|
return {
|
|
708
1186
|
"status": "need_images",
|
|
@@ -724,6 +1202,14 @@ class PublishModule:
|
|
|
724
1202
|
|
|
725
1203
|
draft_requested = getattr(self.args, "draft", False)
|
|
726
1204
|
|
|
1205
|
+
# Sanitize HTML in long description before building payload
|
|
1206
|
+
html_cleaned = False
|
|
1207
|
+
if schema.product_long_description:
|
|
1208
|
+
sanitized, was_cleaned = _sanitize_html(schema.product_long_description)
|
|
1209
|
+
if was_cleaned:
|
|
1210
|
+
html_cleaned = True
|
|
1211
|
+
schema.product_long_description = sanitized
|
|
1212
|
+
|
|
727
1213
|
product_payload = self.product.build_create_product_payload(
|
|
728
1214
|
schema.to_dict(),
|
|
729
1215
|
category_id=category_id,
|
|
@@ -732,11 +1218,33 @@ class PublishModule:
|
|
|
732
1218
|
draft=draft_requested,
|
|
733
1219
|
)
|
|
734
1220
|
|
|
1221
|
+
# Stage 3b: unified compliance check (GPSR + Safety Warning + compliance docs)
|
|
1222
|
+
draft_issues: list = []
|
|
1223
|
+
compliance_check = self._check_compliance_fields(
|
|
1224
|
+
product_payload,
|
|
1225
|
+
category_attributes,
|
|
1226
|
+
schema=schema,
|
|
1227
|
+
draft_requested=draft_requested,
|
|
1228
|
+
draft_issues=draft_issues,
|
|
1229
|
+
)
|
|
1230
|
+
if compliance_check:
|
|
1231
|
+
return compliance_check
|
|
1232
|
+
|
|
1233
|
+
# Stage 3c: special category rules (PPU / ISBN / food attributes)
|
|
1234
|
+
special_check = self._check_special_category_rules(
|
|
1235
|
+
product_payload,
|
|
1236
|
+
category_attributes,
|
|
1237
|
+
schema=schema,
|
|
1238
|
+
draft_requested=draft_requested,
|
|
1239
|
+
draft_issues=draft_issues,
|
|
1240
|
+
)
|
|
1241
|
+
if special_check:
|
|
1242
|
+
return special_check
|
|
1243
|
+
|
|
735
1244
|
# Stage 4a: check category-specific mandatory attributes
|
|
736
1245
|
cat_attr_result = self._check_category_mandatory_attributes(
|
|
737
1246
|
product_payload, category_attributes, category_id
|
|
738
1247
|
)
|
|
739
|
-
draft_issues: list = []
|
|
740
1248
|
if cat_attr_result:
|
|
741
1249
|
if draft_requested:
|
|
742
1250
|
for attr in cat_attr_result.get("missing_attributes", []):
|
|
@@ -768,6 +1276,10 @@ class PublishModule:
|
|
|
768
1276
|
schema, product_payload, compliance_result, category_attributes
|
|
769
1277
|
)
|
|
770
1278
|
country_price_note = self._check_country_price_recommendation(schema)
|
|
1279
|
+
|
|
1280
|
+
# Check for >2 sales attributes (variation_3+)
|
|
1281
|
+
variation_warnings = self._check_extra_variations(schema)
|
|
1282
|
+
|
|
771
1283
|
fs_preview = {
|
|
772
1284
|
"stage": "field_summary",
|
|
773
1285
|
"category_id": int(category_id),
|
|
@@ -775,6 +1287,10 @@ class PublishModule:
|
|
|
775
1287
|
"brand_id": brand_id,
|
|
776
1288
|
"key_missing": sorted(key_missing),
|
|
777
1289
|
"draft": draft_requested,
|
|
1290
|
+
"image_warnings": image_warnings,
|
|
1291
|
+
"variation_warnings": variation_warnings,
|
|
1292
|
+
"html_cleaned": html_cleaned,
|
|
1293
|
+
"description_empty": not schema.product_long_description,
|
|
778
1294
|
}
|
|
779
1295
|
expected_fs_token = _make_publish_preview_token(fs_preview)
|
|
780
1296
|
provided_fs_token = getattr(self.args, "field_summary_token", None)
|
|
@@ -801,81 +1317,175 @@ class PublishModule:
|
|
|
801
1317
|
# ------------------------------------------------------------------
|
|
802
1318
|
|
|
803
1319
|
_TRACKED_FIELDS = [
|
|
804
|
-
|
|
805
|
-
("
|
|
806
|
-
("
|
|
807
|
-
("
|
|
808
|
-
("
|
|
809
|
-
("
|
|
810
|
-
("
|
|
811
|
-
("
|
|
812
|
-
("weight_kg", "Weight"),
|
|
813
|
-
("length_cm", "Length"),
|
|
814
|
-
("width_cm", "Width"),
|
|
815
|
-
("height_cm", "Height"),
|
|
816
|
-
("
|
|
817
|
-
|
|
818
|
-
("
|
|
819
|
-
("
|
|
820
|
-
("
|
|
1320
|
+
# Mandatory fields — missing blocks publish entirely
|
|
1321
|
+
("product_title", "Product title", "mandatory"),
|
|
1322
|
+
("product_images", "Product images", "mandatory"),
|
|
1323
|
+
("category_id", "Category", "mandatory"),
|
|
1324
|
+
("brand_id", "Brand", "mandatory"),
|
|
1325
|
+
("general_price", "General price", "mandatory"),
|
|
1326
|
+
("stock", "Stock", "mandatory"),
|
|
1327
|
+
("seller_sku", "Seller SKU", "mandatory"),
|
|
1328
|
+
("weight_kg", "Weight", "mandatory"),
|
|
1329
|
+
("length_cm", "Length", "mandatory"),
|
|
1330
|
+
("width_cm", "Width", "mandatory"),
|
|
1331
|
+
("height_cm", "Height", "mandatory"),
|
|
1332
|
+
("delivery_method", "Delivery method", "mandatory"),
|
|
1333
|
+
# Conditional fields — missing suggests Draft
|
|
1334
|
+
("italy_price", "Italy price", "conditional"),
|
|
1335
|
+
("ean_code", "EAN code", "conditional"),
|
|
1336
|
+
("manufacturer", "Manufacturer", "conditional"),
|
|
1337
|
+
("eu_responsible_person", "EU responsible person", "conditional"),
|
|
1338
|
+
("safety_warning", "Safety warning", "conditional"),
|
|
1339
|
+
("package_photos", "Package photos", "conditional"),
|
|
1340
|
+
("compliance_documents", "Compliance documents", "conditional"),
|
|
1341
|
+
("standard_unit_type", "Unit count type", "conditional"),
|
|
1342
|
+
("unit_count", "Unit count", "conditional"),
|
|
1343
|
+
("sku_attributes_food_health", "Food & health attributes", "conditional"),
|
|
1344
|
+
("isbn", "ISBN", "conditional"),
|
|
821
1345
|
]
|
|
822
1346
|
|
|
823
1347
|
def _resolve_category_for_schema(
|
|
824
1348
|
self, schema: MiraviaListingSchema
|
|
825
1349
|
) -> Dict[str, Any]:
|
|
826
|
-
"""Resolve category from extracted name/ID
|
|
827
|
-
confirmed_category_id = getattr(self.args, "confirmed_category_id", None)
|
|
828
|
-
if confirmed_category_id:
|
|
829
|
-
return {"category_id": int(confirmed_category_id)}
|
|
1350
|
+
"""Resolve category from extracted name/ID, CLI args, or recommendation.
|
|
830
1351
|
|
|
831
|
-
|
|
832
|
-
|
|
1352
|
+
Priority:
|
|
1353
|
+
1. --confirmed-category-id: validated against tree
|
|
1354
|
+
2. --category-id / schema.category_id: validated against tree
|
|
1355
|
+
3. schema.category_name: keyword search
|
|
1356
|
+
4. Recommend API: title/images → Top1/TopN/empty
|
|
1357
|
+
5. Fallback: full category tree
|
|
833
1358
|
|
|
834
|
-
|
|
835
|
-
|
|
1359
|
+
All "invalid" paths try recommend API before falling back to tree.
|
|
1360
|
+
"""
|
|
1361
|
+
# Fetch category tree once — used for validation and fallback display
|
|
1362
|
+
lang = self.args.lang if self.args.lang not in (None, "auto") else "en"
|
|
1363
|
+
category_tree: Dict[str, Any] = {}
|
|
1364
|
+
valid_ids: set = set()
|
|
1365
|
+
category_permissions: Dict[int, bool] = {}
|
|
1366
|
+
try:
|
|
1367
|
+
category_tree = self.product.get_category_tree(language_code=lang)
|
|
1368
|
+
valid_ids = _collect_category_ids(category_tree)
|
|
1369
|
+
category_permissions = _collect_category_permissions(category_tree)
|
|
1370
|
+
except MiraviaError as exc:
|
|
1371
|
+
logger.warning("Failed to fetch category tree: %s", exc)
|
|
836
1372
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1373
|
+
def _need_category_response(
|
|
1374
|
+
reason_msg: str,
|
|
1375
|
+
choices: Optional[List[Dict[str, Any]]] = None,
|
|
1376
|
+
) -> Dict[str, Any]:
|
|
1377
|
+
resp: Dict[str, Any] = {
|
|
840
1378
|
"status": "need_category",
|
|
841
|
-
"message":
|
|
1379
|
+
"message": reason_msg,
|
|
1380
|
+
"category_tree": category_tree,
|
|
1381
|
+
"category_permissions": category_permissions,
|
|
842
1382
|
"next_actions": [i18n._("pub_ai_post_provide_category")],
|
|
843
1383
|
}
|
|
1384
|
+
if choices:
|
|
1385
|
+
resp["choices"] = choices
|
|
1386
|
+
resp["next_actions"] = [
|
|
1387
|
+
i18n._(
|
|
1388
|
+
"pub_category_confirm_next", category_id=c.get("category_id")
|
|
1389
|
+
)
|
|
1390
|
+
for c in choices
|
|
1391
|
+
]
|
|
1392
|
+
return resp
|
|
1393
|
+
|
|
1394
|
+
def _try_recommend_or_tree(
|
|
1395
|
+
fallback_msg: str,
|
|
1396
|
+
) -> Dict[str, Any]:
|
|
1397
|
+
"""Try recommend API first, fallback to category tree."""
|
|
1398
|
+
title = schema.product_title
|
|
1399
|
+
image_urls = schema.image_urls or []
|
|
1400
|
+
if title or image_urls:
|
|
1401
|
+
try:
|
|
1402
|
+
recommendations = self.product.recommend_category(
|
|
1403
|
+
title=title, image_urls=image_urls if image_urls else None
|
|
1404
|
+
)
|
|
1405
|
+
if recommendations:
|
|
1406
|
+
# Top1: auto-select
|
|
1407
|
+
if len(recommendations) == 1:
|
|
1408
|
+
rec = recommendations[0]
|
|
1409
|
+
cid = rec.get("category_id") or rec.get("id")
|
|
1410
|
+
if cid:
|
|
1411
|
+
return {"category_id": int(cid)}
|
|
1412
|
+
# TopN: show candidates
|
|
1413
|
+
return _need_category_response(
|
|
1414
|
+
i18n._(
|
|
1415
|
+
"pub_ai_post_category_recommend",
|
|
1416
|
+
count=len(recommendations),
|
|
1417
|
+
),
|
|
1418
|
+
choices=recommendations,
|
|
1419
|
+
)
|
|
1420
|
+
except MiraviaError as exc:
|
|
1421
|
+
logger.warning("Category recommendation failed: %s", exc)
|
|
1422
|
+
# Fallback: show full tree
|
|
1423
|
+
return _need_category_response(fallback_msg)
|
|
1424
|
+
|
|
1425
|
+
def _validate_category_id(cid: int) -> Optional[Dict[str, Any]]:
|
|
1426
|
+
"""Validate category ID. If invalid, try recommend then tree."""
|
|
1427
|
+
if valid_ids and cid not in valid_ids:
|
|
1428
|
+
return _try_recommend_or_tree(
|
|
1429
|
+
i18n._("pub_ai_post_category_id_invalid", category_id=cid)
|
|
1430
|
+
)
|
|
1431
|
+
return None
|
|
844
1432
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
}
|
|
1433
|
+
# 1. --confirmed-category-id: validate
|
|
1434
|
+
confirmed_category_id = getattr(self.args, "confirmed_category_id", None)
|
|
1435
|
+
if confirmed_category_id:
|
|
1436
|
+
cid = int(confirmed_category_id)
|
|
1437
|
+
err = _validate_category_id(cid)
|
|
1438
|
+
if err:
|
|
1439
|
+
return err
|
|
1440
|
+
return {"category_id": cid}
|
|
854
1441
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
if
|
|
860
|
-
|
|
861
|
-
|
|
1442
|
+
# 2. --category-id: validate
|
|
1443
|
+
if self.args.category_id:
|
|
1444
|
+
cid = int(self.args.category_id)
|
|
1445
|
+
err = _validate_category_id(cid)
|
|
1446
|
+
if err:
|
|
1447
|
+
return err
|
|
1448
|
+
return {"category_id": cid}
|
|
862
1449
|
|
|
863
|
-
|
|
864
|
-
|
|
1450
|
+
# 3. schema.category_id: validate
|
|
1451
|
+
if schema.category_id:
|
|
1452
|
+
cid = int(schema.category_id)
|
|
1453
|
+
err = _validate_category_id(cid)
|
|
1454
|
+
if err:
|
|
1455
|
+
return err
|
|
1456
|
+
return {"category_id": cid}
|
|
865
1457
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1458
|
+
# 4. schema.category_name: keyword search
|
|
1459
|
+
category_name = schema.category_name
|
|
1460
|
+
if category_name:
|
|
1461
|
+
candidates = self.product.search_category_by_keyword(category_name)
|
|
1462
|
+
if candidates:
|
|
1463
|
+
name_lower = category_name.strip().lower()
|
|
1464
|
+
exact = [
|
|
1465
|
+
c
|
|
1466
|
+
for c in candidates
|
|
1467
|
+
if (c.get("name") or "").strip().lower() == name_lower
|
|
1468
|
+
]
|
|
1469
|
+
display_candidates = exact if exact else candidates
|
|
1470
|
+
|
|
1471
|
+
if len(display_candidates) == 1:
|
|
1472
|
+
return {"category_id": display_candidates[0].get("category_id")}
|
|
1473
|
+
|
|
1474
|
+
return _need_category_response(
|
|
1475
|
+
i18n._(
|
|
1476
|
+
"pub_ai_post_category_multiple", count=len(display_candidates)
|
|
1477
|
+
),
|
|
1478
|
+
choices=display_candidates,
|
|
875
1479
|
)
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1480
|
+
# Keyword search failed → try recommend then tree
|
|
1481
|
+
return _try_recommend_or_tree(
|
|
1482
|
+
i18n._("pub_ai_post_category_not_found", name=category_name)
|
|
1483
|
+
)
|
|
1484
|
+
|
|
1485
|
+
# 5. No category info at all → try recommend then tree
|
|
1486
|
+
return _try_recommend_or_tree(
|
|
1487
|
+
i18n._("pub_ai_post_need_category")
|
|
1488
|
+
)
|
|
879
1489
|
|
|
880
1490
|
def _resolve_brand_for_schema(
|
|
881
1491
|
self, schema: MiraviaListingSchema
|
|
@@ -1122,6 +1732,10 @@ class PublishModule:
|
|
|
1122
1732
|
if not has_price:
|
|
1123
1733
|
missing.append("General price")
|
|
1124
1734
|
|
|
1735
|
+
has_stock = any(sku.get("quantity") for sku in skus)
|
|
1736
|
+
if not has_stock:
|
|
1737
|
+
missing.append("Stock")
|
|
1738
|
+
|
|
1125
1739
|
package_dims = ("package_weight", "package_length", "package_width", "package_height")
|
|
1126
1740
|
for idx, sku in enumerate(skus, start=1):
|
|
1127
1741
|
if not sku.get("SellerSku"):
|
|
@@ -1152,47 +1766,51 @@ class PublishModule:
|
|
|
1152
1766
|
compliance_result: Dict[str, Any],
|
|
1153
1767
|
category_attributes: List[Dict[str, Any]],
|
|
1154
1768
|
) -> List[str]:
|
|
1155
|
-
"""Return
|
|
1769
|
+
"""Return internal keys of fields that are missing but can be skipped in draft mode.
|
|
1770
|
+
|
|
1771
|
+
Keys match ``_TRACKED_FIELDS`` internal names so the summary preview
|
|
1772
|
+
can correctly mark them as missing.
|
|
1773
|
+
"""
|
|
1156
1774
|
missing: List[str] = []
|
|
1157
1775
|
skus = (product_payload.get("Skus") or {}).get("Sku") or []
|
|
1158
1776
|
has_ean = any(sku.get("ean_code") for sku in skus) or bool(schema.ean_codes)
|
|
1159
1777
|
if not has_ean:
|
|
1160
|
-
missing.append("
|
|
1778
|
+
missing.append("ean_code")
|
|
1161
1779
|
|
|
1162
1780
|
if self._check_italy_price_mandatory(schema):
|
|
1163
|
-
missing.append("
|
|
1781
|
+
missing.append("italy_price")
|
|
1164
1782
|
|
|
1165
1783
|
if schema.standard_unit_type and not schema.unit_count:
|
|
1166
|
-
missing.append("
|
|
1784
|
+
missing.append("unit_count")
|
|
1167
1785
|
elif self._is_category_attribute_mandatory(category_attributes, "unit_count") and not schema.unit_count:
|
|
1168
|
-
missing.append("
|
|
1786
|
+
missing.append("unit_count")
|
|
1169
1787
|
|
|
1170
1788
|
if not schema.sku_attributes_food_health and self._category_looks_like_food_health(
|
|
1171
1789
|
category_attributes
|
|
1172
1790
|
):
|
|
1173
|
-
missing.append("
|
|
1791
|
+
missing.append("sku_attributes_food_health")
|
|
1174
1792
|
|
|
1175
1793
|
if compliance_result.get("manufacturer_mismatch") or (
|
|
1176
1794
|
not schema.manufacturer_id and not schema.manufacturer_name
|
|
1177
1795
|
):
|
|
1178
|
-
missing.append("
|
|
1796
|
+
missing.append("manufacturer")
|
|
1179
1797
|
if compliance_result.get("eurp_mismatch") or (
|
|
1180
1798
|
not schema.eu_responsible_person_id
|
|
1181
1799
|
and not schema.eu_responsible_person_name
|
|
1182
1800
|
):
|
|
1183
|
-
missing.append("
|
|
1801
|
+
missing.append("eu_responsible_person")
|
|
1184
1802
|
|
|
1185
1803
|
if schema.safety_warning is None:
|
|
1186
|
-
missing.append("
|
|
1804
|
+
missing.append("safety_warning")
|
|
1187
1805
|
|
|
1188
1806
|
if not schema.package_photos:
|
|
1189
|
-
missing.append("
|
|
1807
|
+
missing.append("package_photos")
|
|
1190
1808
|
|
|
1191
1809
|
if self._is_category_attribute_mandatory(category_attributes, "compliance_documents") and not schema.compliance_documents:
|
|
1192
|
-
missing.append("
|
|
1810
|
+
missing.append("compliance_documents")
|
|
1193
1811
|
|
|
1194
1812
|
if not any(sku.get("SellerSku") for sku in skus):
|
|
1195
|
-
missing.append("
|
|
1813
|
+
missing.append("seller_sku")
|
|
1196
1814
|
|
|
1197
1815
|
return missing
|
|
1198
1816
|
|
|
@@ -1244,32 +1862,105 @@ class PublishModule:
|
|
|
1244
1862
|
return True
|
|
1245
1863
|
return False
|
|
1246
1864
|
|
|
1865
|
+
def _check_extra_variations(self, schema: MiraviaListingSchema) -> List[str]:
|
|
1866
|
+
"""Check if raw_skus contain variation_3+ fields (Miravia supports max 2).
|
|
1867
|
+
|
|
1868
|
+
Returns a list of warning messages if extra variations are detected.
|
|
1869
|
+
"""
|
|
1870
|
+
warnings: List[str] = []
|
|
1871
|
+
extra_vars: set = set()
|
|
1872
|
+
|
|
1873
|
+
for raw in schema.raw_skus:
|
|
1874
|
+
if not isinstance(raw, dict):
|
|
1875
|
+
continue
|
|
1876
|
+
for key in raw.keys():
|
|
1877
|
+
# Check for variation_3, variation_4, etc.
|
|
1878
|
+
if key.startswith("variation_"):
|
|
1879
|
+
try:
|
|
1880
|
+
num = int(key.split("_")[1])
|
|
1881
|
+
if num > 2:
|
|
1882
|
+
extra_vars.add(key)
|
|
1883
|
+
except (IndexError, ValueError):
|
|
1884
|
+
pass
|
|
1885
|
+
|
|
1886
|
+
if extra_vars:
|
|
1887
|
+
sorted_vars = sorted(extra_vars)
|
|
1888
|
+
warnings.append(
|
|
1889
|
+
i18n._(
|
|
1890
|
+
"pub_too_many_variations",
|
|
1891
|
+
count=len(sorted_vars),
|
|
1892
|
+
variations=", ".join(sorted_vars),
|
|
1893
|
+
)
|
|
1894
|
+
)
|
|
1895
|
+
|
|
1896
|
+
return warnings
|
|
1897
|
+
|
|
1247
1898
|
def _make_field_summary_preview(
|
|
1248
1899
|
self,
|
|
1249
1900
|
fs_preview: Dict[str, Any],
|
|
1250
1901
|
key_missing: List[str],
|
|
1251
1902
|
country_price_note: Optional[str] = None,
|
|
1252
1903
|
) -> Dict[str, Any]:
|
|
1253
|
-
"""Return the field extraction summary and draft/continue prompt.
|
|
1904
|
+
"""Return the field extraction summary and draft/continue prompt.
|
|
1905
|
+
|
|
1906
|
+
Fields are grouped by Mandatory vs Conditional. Missing mandatory
|
|
1907
|
+
fields block publish; missing conditional fields suggest Draft.
|
|
1908
|
+
"""
|
|
1254
1909
|
token = _make_publish_preview_token(fs_preview)
|
|
1255
|
-
|
|
1256
|
-
|
|
1910
|
+
|
|
1911
|
+
mandatory_lines: List[str] = []
|
|
1912
|
+
conditional_lines: List[str] = []
|
|
1913
|
+
for key, label, category in self._TRACKED_FIELDS:
|
|
1257
1914
|
status = (
|
|
1258
1915
|
i18n._("pub_field_status_missing")
|
|
1259
1916
|
if key in key_missing
|
|
1260
1917
|
else i18n._("pub_field_status_filled")
|
|
1261
1918
|
)
|
|
1262
|
-
|
|
1919
|
+
line = f" {label}: {status}"
|
|
1920
|
+
if category == "mandatory":
|
|
1921
|
+
mandatory_lines.append(line)
|
|
1922
|
+
else:
|
|
1923
|
+
conditional_lines.append(line)
|
|
1924
|
+
|
|
1925
|
+
# Build summary with section headers
|
|
1926
|
+
summary: List[str] = []
|
|
1927
|
+
summary.append(i18n._("pub_summary_mandatory_section"))
|
|
1928
|
+
summary.extend(mandatory_lines)
|
|
1929
|
+
summary.append("")
|
|
1930
|
+
summary.append(i18n._("pub_summary_conditional_section"))
|
|
1931
|
+
summary.extend(conditional_lines)
|
|
1932
|
+
|
|
1933
|
+
# Separate mandatory vs conditional missing
|
|
1934
|
+
mandatory_missing = [
|
|
1935
|
+
label for key, label, cat in self._TRACKED_FIELDS
|
|
1936
|
+
if cat == "mandatory" and key in key_missing
|
|
1937
|
+
]
|
|
1938
|
+
conditional_missing = [
|
|
1939
|
+
label for key, label, cat in self._TRACKED_FIELDS
|
|
1940
|
+
if cat == "conditional" and key in key_missing
|
|
1941
|
+
]
|
|
1263
1942
|
|
|
1264
1943
|
actions = [i18n._("pub_final_confirm_next", token=token)]
|
|
1265
|
-
if
|
|
1266
|
-
|
|
1944
|
+
if mandatory_missing:
|
|
1945
|
+
# Mandatory missing — block with clear message
|
|
1946
|
+
actions.insert(0, i18n._(
|
|
1947
|
+
"pub_mandatory_missing_block",
|
|
1948
|
+
fields=", ".join(mandatory_missing),
|
|
1949
|
+
))
|
|
1950
|
+
elif conditional_missing:
|
|
1951
|
+
# Conditional missing — suggest Draft
|
|
1952
|
+
actions.insert(0, i18n._(
|
|
1953
|
+
"pub_conditional_missing_draft",
|
|
1954
|
+
fields=", ".join(conditional_missing),
|
|
1955
|
+
))
|
|
1267
1956
|
|
|
1268
1957
|
payload: Dict[str, Any] = {
|
|
1269
1958
|
"status": "need_field_summary",
|
|
1270
1959
|
"title": i18n._("pub_field_summary_title"),
|
|
1271
1960
|
"message": i18n._("pub_field_summary_description"),
|
|
1272
|
-
"summary":
|
|
1961
|
+
"summary": summary,
|
|
1962
|
+
"mandatory_missing": mandatory_missing,
|
|
1963
|
+
"conditional_missing": conditional_missing,
|
|
1273
1964
|
"key_missing": key_missing,
|
|
1274
1965
|
"field_summary_token": token,
|
|
1275
1966
|
"next_actions": actions,
|
|
@@ -1438,8 +2129,12 @@ class PublishModule:
|
|
|
1438
2129
|
|
|
1439
2130
|
def _process_images_for_schema(
|
|
1440
2131
|
self, schema: MiraviaListingSchema
|
|
1441
|
-
) -> Tuple[List[str], List[str]]:
|
|
1442
|
-
"""Migrate URLs and upload local image files, returning CDN URLs.
|
|
2132
|
+
) -> Tuple[List[str], List[str], List[str]]:
|
|
2133
|
+
"""Migrate URLs and upload local image files, returning CDN URLs.
|
|
2134
|
+
|
|
2135
|
+
Returns:
|
|
2136
|
+
Tuple of (migrated_urls, failed_urls, warnings)
|
|
2137
|
+
"""
|
|
1443
2138
|
from pathlib import Path
|
|
1444
2139
|
|
|
1445
2140
|
all_images: List[str] = []
|
|
@@ -1462,7 +2157,14 @@ class PublishModule:
|
|
|
1462
2157
|
|
|
1463
2158
|
migrated: List[str] = []
|
|
1464
2159
|
failed: List[str] = []
|
|
1465
|
-
|
|
2160
|
+
warnings: List[str] = []
|
|
2161
|
+
for idx, img in enumerate(unique_images, start=1):
|
|
2162
|
+
# Check white background for local files (non-blocking warning)
|
|
2163
|
+
if not _check_white_background(img):
|
|
2164
|
+
warnings.append(
|
|
2165
|
+
i18n._("pub_white_background_warning", idx=idx)
|
|
2166
|
+
)
|
|
2167
|
+
|
|
1466
2168
|
if _is_miravia_cdn(img):
|
|
1467
2169
|
migrated.append(img)
|
|
1468
2170
|
elif img.startswith("http://") or img.startswith("https://"):
|
|
@@ -1479,7 +2181,7 @@ class PublishModule:
|
|
|
1479
2181
|
failed.append(img)
|
|
1480
2182
|
else:
|
|
1481
2183
|
failed.append(img)
|
|
1482
|
-
return migrated, failed
|
|
2184
|
+
return migrated, failed, warnings
|
|
1483
2185
|
|
|
1484
2186
|
def _build_sku_list_from_schema(
|
|
1485
2187
|
self, schema: MiraviaListingSchema
|
|
@@ -1597,6 +2299,31 @@ class PublishModule:
|
|
|
1597
2299
|
errors.extend(vr.get("errors", []))
|
|
1598
2300
|
|
|
1599
2301
|
if errors:
|
|
2302
|
+
# Check for category permission errors first
|
|
2303
|
+
has_category_permission_error = any(
|
|
2304
|
+
"category" in str(e.get("field", "")).lower()
|
|
2305
|
+
and ("permission" in str(e.get("msg", "")).lower()
|
|
2306
|
+
or "not allow" in str(e.get("msg", "")).lower()
|
|
2307
|
+
or "no access" in str(e.get("msg", "")).lower())
|
|
2308
|
+
for e in errors
|
|
2309
|
+
)
|
|
2310
|
+
if has_category_permission_error:
|
|
2311
|
+
category_id = product_payload.get("Attributes", {}).get("PrimaryCategory", "")
|
|
2312
|
+
return {
|
|
2313
|
+
"status": "need_category_permission",
|
|
2314
|
+
"title": i18n._("pub_category_permission_title"),
|
|
2315
|
+
"message": i18n._(
|
|
2316
|
+
"pub_category_permission_message",
|
|
2317
|
+
category_name=str(category_id),
|
|
2318
|
+
category_id=str(category_id),
|
|
2319
|
+
),
|
|
2320
|
+
"apply_link": i18n._("pub_category_permission_apply_link"),
|
|
2321
|
+
"next_actions": [
|
|
2322
|
+
i18n._("pub_category_permission_draft_next"),
|
|
2323
|
+
i18n._("pub_category_permission_change_next"),
|
|
2324
|
+
],
|
|
2325
|
+
}
|
|
2326
|
+
|
|
1600
2327
|
guides = self._field_definition_guides(errors)
|
|
1601
2328
|
next_actions: List[str] = [
|
|
1602
2329
|
i18n._("pub_ai_post_prevalidate_fix"),
|
|
@@ -2067,6 +2794,60 @@ class PublishModule:
|
|
|
2067
2794
|
# ---------------------------------------------------------------------------
|
|
2068
2795
|
|
|
2069
2796
|
|
|
2797
|
+
def _collect_category_ids(tree: Any) -> set:
|
|
2798
|
+
"""Recursively collect all category IDs from a category tree structure.
|
|
2799
|
+
|
|
2800
|
+
Handles both dict and list structures, extracting category_id / id fields.
|
|
2801
|
+
Returns an empty set if the tree is empty or malformed.
|
|
2802
|
+
"""
|
|
2803
|
+
ids: set = set()
|
|
2804
|
+
if isinstance(tree, dict):
|
|
2805
|
+
# Try common tree root keys
|
|
2806
|
+
categories = tree.get("categories") or tree.get("data") or tree.get("children") or []
|
|
2807
|
+
if isinstance(categories, list):
|
|
2808
|
+
for cat in categories:
|
|
2809
|
+
ids |= _collect_category_ids(cat)
|
|
2810
|
+
else:
|
|
2811
|
+
# Single category node
|
|
2812
|
+
cid = tree.get("category_id") or tree.get("id")
|
|
2813
|
+
if cid:
|
|
2814
|
+
ids.add(int(cid))
|
|
2815
|
+
children = tree.get("children") or tree.get("sub_categories") or []
|
|
2816
|
+
if isinstance(children, list):
|
|
2817
|
+
ids |= _collect_category_ids(children)
|
|
2818
|
+
elif isinstance(tree, list):
|
|
2819
|
+
for item in tree:
|
|
2820
|
+
ids |= _collect_category_ids(item)
|
|
2821
|
+
return ids
|
|
2822
|
+
|
|
2823
|
+
|
|
2824
|
+
def _collect_category_permissions(tree: Any) -> Dict[int, bool]:
|
|
2825
|
+
"""Recursively collect category permission info from tree.
|
|
2826
|
+
|
|
2827
|
+
Returns a dict mapping category_id -> has_permission (bool).
|
|
2828
|
+
Permission field values: "yes" -> True, "no" -> False.
|
|
2829
|
+
"""
|
|
2830
|
+
permissions: Dict[int, bool] = {}
|
|
2831
|
+
if isinstance(tree, dict):
|
|
2832
|
+
categories = tree.get("categories") or tree.get("data") or tree.get("children") or []
|
|
2833
|
+
if isinstance(categories, list):
|
|
2834
|
+
for cat in categories:
|
|
2835
|
+
permissions.update(_collect_category_permissions(cat))
|
|
2836
|
+
else:
|
|
2837
|
+
# Single category node
|
|
2838
|
+
cid = tree.get("category_id") or tree.get("id")
|
|
2839
|
+
if cid:
|
|
2840
|
+
perm = tree.get("permission", "").lower()
|
|
2841
|
+
permissions[int(cid)] = perm == "yes"
|
|
2842
|
+
children = tree.get("children") or tree.get("sub_categories") or []
|
|
2843
|
+
if isinstance(children, list):
|
|
2844
|
+
permissions.update(_collect_category_permissions(children))
|
|
2845
|
+
elif isinstance(tree, list):
|
|
2846
|
+
for item in tree:
|
|
2847
|
+
permissions.update(_collect_category_permissions(item))
|
|
2848
|
+
return permissions
|
|
2849
|
+
|
|
2850
|
+
|
|
2070
2851
|
def _is_miravia_cdn(url: str) -> bool:
|
|
2071
2852
|
"""Check whether a URL is already on Miravia CDN."""
|
|
2072
2853
|
url_lower = url.lower()
|
|
@@ -2122,6 +2903,11 @@ def _validate_create_payload(payload: Dict[str, Any]) -> None:
|
|
|
2122
2903
|
if len(ean_codes) != len(set(ean_codes)):
|
|
2123
2904
|
raise ValueError(i18n._("pub_ean_duplicate_in_product"))
|
|
2124
2905
|
|
|
2906
|
+
# Check Seller SKU duplicate within same product
|
|
2907
|
+
seller_skus = [sku.get("SellerSku") for sku in skus if sku.get("SellerSku")]
|
|
2908
|
+
if len(seller_skus) != len(set(seller_skus)):
|
|
2909
|
+
raise ValueError(i18n._("pub_seller_sku_duplicate_in_product"))
|
|
2910
|
+
|
|
2125
2911
|
for idx, sku in enumerate(skus, start=1):
|
|
2126
2912
|
if not sku.get("SellerSku"):
|
|
2127
2913
|
raise ValueError(i18n._("pub_seller_sku_required", idx=idx))
|
|
@@ -2140,9 +2926,22 @@ def _validate_create_payload(payload: Dict[str, Any]) -> None:
|
|
|
2140
2926
|
_validate_ean(ean, idx)
|
|
2141
2927
|
|
|
2142
2928
|
# Per-SKU package dimensions/weight
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2929
|
+
weight = sku.get("package_weight")
|
|
2930
|
+
length = sku.get("package_length")
|
|
2931
|
+
width = sku.get("package_width")
|
|
2932
|
+
height = sku.get("package_height")
|
|
2933
|
+
|
|
2934
|
+
# Check: if weight is present, L/W/H must also be present
|
|
2935
|
+
if weight and not (length and width and height):
|
|
2936
|
+
raise ValueError(i18n._("pub_dimension_incomplete", idx=idx))
|
|
2937
|
+
|
|
2938
|
+
_validate_dimension(weight, "weight", _MAX_WEIGHT_KG)
|
|
2939
|
+
for dim_name, dim_value in (
|
|
2940
|
+
("package_length", length),
|
|
2941
|
+
("package_width", width),
|
|
2942
|
+
("package_height", height),
|
|
2943
|
+
):
|
|
2944
|
+
_validate_dimension(dim_value, dim_name, _MAX_DIMENSION_CM)
|
|
2146
2945
|
|
|
2147
2946
|
safety = attributes.get("safety_warning")
|
|
2148
2947
|
if safety and safety not in ("Yes", "No"):
|
|
@@ -2235,13 +3034,138 @@ def _validate_image_format(image_url: str, idx: int) -> None:
|
|
|
2235
3034
|
)
|
|
2236
3035
|
|
|
2237
3036
|
|
|
3037
|
+
def _check_white_background(image_url: str) -> bool:
|
|
3038
|
+
"""Check if image has white background.
|
|
3039
|
+
|
|
3040
|
+
Samples pixels from corners and edges. Returns True if background
|
|
3041
|
+
appears to be white (RGB >= 240), False otherwise.
|
|
3042
|
+
Only works for local files; returns True for remote URLs (skip check).
|
|
3043
|
+
"""
|
|
3044
|
+
from pathlib import Path
|
|
3045
|
+
|
|
3046
|
+
# Only check local files
|
|
3047
|
+
if not (image_url.startswith("/") or image_url.startswith("file://")):
|
|
3048
|
+
return True # Skip check for remote URLs
|
|
3049
|
+
|
|
3050
|
+
local_path = image_url.replace("file://", "")
|
|
3051
|
+
path = Path(local_path)
|
|
3052
|
+
if not path.exists():
|
|
3053
|
+
return True # Skip if file not found
|
|
3054
|
+
|
|
3055
|
+
try:
|
|
3056
|
+
from PIL import Image
|
|
3057
|
+
|
|
3058
|
+
img = Image.open(path)
|
|
3059
|
+
width, height = img.size
|
|
3060
|
+
if width < 10 or height < 10:
|
|
3061
|
+
return True # Too small to check
|
|
3062
|
+
|
|
3063
|
+
# Convert to RGB if necessary
|
|
3064
|
+
if img.mode != "RGB":
|
|
3065
|
+
img = img.convert("RGB")
|
|
3066
|
+
|
|
3067
|
+
# Sample pixels from corners and edges
|
|
3068
|
+
sample_points = [
|
|
3069
|
+
(0, 0), # Top-left
|
|
3070
|
+
(width - 1, 0), # Top-right
|
|
3071
|
+
(0, height - 1), # Bottom-left
|
|
3072
|
+
(width - 1, height - 1), # Bottom-right
|
|
3073
|
+
(width // 2, 0), # Top-center
|
|
3074
|
+
(width // 2, height - 1), # Bottom-center
|
|
3075
|
+
(0, height // 2), # Left-center
|
|
3076
|
+
(width - 1, height // 2), # Right-center
|
|
3077
|
+
]
|
|
3078
|
+
|
|
3079
|
+
white_count = 0
|
|
3080
|
+
for x, y in sample_points:
|
|
3081
|
+
pixel = img.getpixel((x, y))
|
|
3082
|
+
# Check if pixel is white or near-white (RGB >= 240)
|
|
3083
|
+
if all(c >= 240 for c in pixel[:3]):
|
|
3084
|
+
white_count += 1
|
|
3085
|
+
|
|
3086
|
+
# If 6+ out of 8 sample points are white, consider it white background
|
|
3087
|
+
return white_count >= 6
|
|
3088
|
+
|
|
3089
|
+
except Exception:
|
|
3090
|
+
return True # Skip check if PIL not available or error occurs
|
|
3091
|
+
|
|
3092
|
+
|
|
3093
|
+
# Unit conversion constants
|
|
3094
|
+
_WEIGHT_TO_KG = {
|
|
3095
|
+
"kg": 1.0,
|
|
3096
|
+
"g": 0.001,
|
|
3097
|
+
"t": 1000.0,
|
|
3098
|
+
"oz": 0.02835,
|
|
3099
|
+
"lb": 0.453592,
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
_LENGTH_TO_CM = {
|
|
3103
|
+
"cm": 1.0,
|
|
3104
|
+
"mm": 0.1,
|
|
3105
|
+
"m": 100.0,
|
|
3106
|
+
"in": 2.54,
|
|
3107
|
+
"ft": 30.48,
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
|
|
3111
|
+
def _convert_weight(value: str) -> float:
|
|
3112
|
+
"""Convert weight string to kg.
|
|
3113
|
+
|
|
3114
|
+
Handles: '1000 oz', '500g', '1t', '2.5 kg', etc.
|
|
3115
|
+
Returns value in kg.
|
|
3116
|
+
"""
|
|
3117
|
+
import re
|
|
3118
|
+
|
|
3119
|
+
text = str(value).strip().lower()
|
|
3120
|
+
match = re.match(r"([\d.]+)\s*(kg|g|t|oz|lb)?", text)
|
|
3121
|
+
if not match:
|
|
3122
|
+
raise ValueError(f"Invalid weight format: {value}")
|
|
3123
|
+
number = float(match.group(1))
|
|
3124
|
+
unit = match.group(2) or "kg"
|
|
3125
|
+
if unit not in _WEIGHT_TO_KG:
|
|
3126
|
+
raise ValueError(f"Unknown weight unit: {unit}")
|
|
3127
|
+
return number * _WEIGHT_TO_KG[unit]
|
|
3128
|
+
|
|
3129
|
+
|
|
3130
|
+
def _convert_length(value: str) -> float:
|
|
3131
|
+
"""Convert length string to cm.
|
|
3132
|
+
|
|
3133
|
+
Handles: '10 in', '155 mm', '1.1m', '1ft', '25.4 cm', etc.
|
|
3134
|
+
Returns value in cm.
|
|
3135
|
+
"""
|
|
3136
|
+
import re
|
|
3137
|
+
|
|
3138
|
+
text = str(value).strip().lower()
|
|
3139
|
+
match = re.match(r"([\d.]+)\s*(cm|mm|m|in|ft)?", text)
|
|
3140
|
+
if not match:
|
|
3141
|
+
raise ValueError(f"Invalid length format: {value}")
|
|
3142
|
+
number = float(match.group(1))
|
|
3143
|
+
unit = match.group(2) or "cm"
|
|
3144
|
+
if unit not in _LENGTH_TO_CM:
|
|
3145
|
+
raise ValueError(f"Unknown length unit: {unit}")
|
|
3146
|
+
return number * _LENGTH_TO_CM[unit]
|
|
3147
|
+
|
|
3148
|
+
|
|
2238
3149
|
def _validate_dimension(value: Any, name: str, maximum: int) -> None:
|
|
3150
|
+
"""Validate dimension value after unit conversion.
|
|
3151
|
+
|
|
3152
|
+
For weight: converts to kg, checks 0 < value <= _MAX_WEIGHT_KG
|
|
3153
|
+
For dimensions: converts to cm, checks 0 < value <= _MAX_DIMENSION_CM
|
|
3154
|
+
"""
|
|
2239
3155
|
if value is None:
|
|
2240
3156
|
return
|
|
2241
3157
|
try:
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
3158
|
+
# Try unit conversion first
|
|
3159
|
+
if name == "weight":
|
|
3160
|
+
number = _convert_weight(value)
|
|
3161
|
+
else:
|
|
3162
|
+
number = _convert_length(value)
|
|
3163
|
+
except (TypeError, ValueError, AttributeError) as exc:
|
|
3164
|
+
# Fallback to simple float conversion
|
|
3165
|
+
try:
|
|
3166
|
+
number = float(value)
|
|
3167
|
+
except (TypeError, ValueError) as exc2:
|
|
3168
|
+
raise ValueError(i18n._("pub_invalid_dimension", name=name, value=value)) from exc2
|
|
2245
3169
|
if number <= 0 or number > maximum:
|
|
2246
3170
|
raise ValueError(
|
|
2247
3171
|
i18n._(
|
|
@@ -2253,6 +3177,77 @@ def _validate_dimension(value: Any, name: str, maximum: int) -> None:
|
|
|
2253
3177
|
)
|
|
2254
3178
|
|
|
2255
3179
|
|
|
3180
|
+
# Safe HTML tags for product descriptions (Miravia product editor supports these)
|
|
3181
|
+
_SAFE_HTML_TAGS = frozenset({
|
|
3182
|
+
"p", "br", "b", "strong", "i", "em", "u",
|
|
3183
|
+
"ul", "ol", "li",
|
|
3184
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
3185
|
+
"table", "tr", "td", "th", "thead", "tbody",
|
|
3186
|
+
"a", "img",
|
|
3187
|
+
})
|
|
3188
|
+
|
|
3189
|
+
|
|
3190
|
+
def _sanitize_html(html: str) -> tuple:
|
|
3191
|
+
"""Sanitize HTML content for product description.
|
|
3192
|
+
|
|
3193
|
+
Keeps only safe tags (p, br, b, ul, li, etc.) and removes:
|
|
3194
|
+
- <script>, <iframe>, <object>, <embed> and similar
|
|
3195
|
+
- style attributes, onclick/onerror handlers
|
|
3196
|
+
- <div> with style attributes
|
|
3197
|
+
|
|
3198
|
+
Returns:
|
|
3199
|
+
Tuple of (cleaned_html, was_cleaned: bool)
|
|
3200
|
+
"""
|
|
3201
|
+
import re
|
|
3202
|
+
|
|
3203
|
+
if not html:
|
|
3204
|
+
return html, False
|
|
3205
|
+
|
|
3206
|
+
original = html
|
|
3207
|
+
cleaned = html
|
|
3208
|
+
|
|
3209
|
+
# Remove dangerous tags entirely (including content)
|
|
3210
|
+
dangerous_tags = ["script", "iframe", "object", "embed", "applet", "form", "input"]
|
|
3211
|
+
for tag in dangerous_tags:
|
|
3212
|
+
cleaned = re.sub(
|
|
3213
|
+
rf"<{tag}[^>]*>.*?</{tag}>",
|
|
3214
|
+
"",
|
|
3215
|
+
cleaned,
|
|
3216
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
3217
|
+
)
|
|
3218
|
+
# Also remove self-closing or unclosed dangerous tags
|
|
3219
|
+
cleaned = re.sub(
|
|
3220
|
+
rf"<{tag}[^>]*/?>",
|
|
3221
|
+
"",
|
|
3222
|
+
cleaned,
|
|
3223
|
+
flags=re.IGNORECASE,
|
|
3224
|
+
)
|
|
3225
|
+
|
|
3226
|
+
# Remove style attributes from all tags
|
|
3227
|
+
cleaned = re.sub(r'\s+style\s*=\s*["\'][^"\']*["\']', "", cleaned, flags=re.IGNORECASE)
|
|
3228
|
+
|
|
3229
|
+
# Remove event handlers (onclick, onerror, onload, etc.)
|
|
3230
|
+
cleaned = re.sub(r'\s+on\w+\s*=\s*["\'][^"\']*["\']', "", cleaned, flags=re.IGNORECASE)
|
|
3231
|
+
|
|
3232
|
+
# Remove <div> tags but keep content (div is not in safe list but content is useful)
|
|
3233
|
+
cleaned = re.sub(r"</?div[^>]*>", "", cleaned, flags=re.IGNORECASE)
|
|
3234
|
+
|
|
3235
|
+
# Remove any tags not in the safe list
|
|
3236
|
+
def _replace_unsafe_tag(match):
|
|
3237
|
+
full_match = match.group(0)
|
|
3238
|
+
tag_match = re.match(r"</?([a-zA-Z][a-zA-Z0-9]*)", full_match)
|
|
3239
|
+
if tag_match:
|
|
3240
|
+
tag_name = tag_match.group(1).lower()
|
|
3241
|
+
if tag_name not in _SAFE_HTML_TAGS:
|
|
3242
|
+
return "" # Remove unsafe tag
|
|
3243
|
+
return full_match # Keep safe tag
|
|
3244
|
+
|
|
3245
|
+
cleaned = re.sub(r"</?[a-zA-Z][a-zA-Z0-9]*[^>]*>", _replace_unsafe_tag, cleaned)
|
|
3246
|
+
|
|
3247
|
+
was_cleaned = cleaned != original
|
|
3248
|
+
return cleaned, was_cleaned
|
|
3249
|
+
|
|
3250
|
+
|
|
2256
3251
|
def _load_preview_secret() -> bytes:
|
|
2257
3252
|
"""Return a machine-local secret for publish preview tokens.
|
|
2258
3253
|
|