@miravia_test/miravia-publish-skill 0.1.7 → 0.1.8
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 +1 -0
- package/dist/VERSION +1 -1
- package/dist/scripts/extraction_engine.py +2 -0
- package/dist/scripts/i18n.py +8 -0
- package/dist/scripts/product_client.py +4 -2
- package/dist/scripts/product_skill.py +19 -0
- package/dist/scripts/publish_module.py +197 -25
- package/package.json +1 -1
package/dist/SKILL.md
CHANGED
|
@@ -112,6 +112,7 @@ description: Miravia 商品发布 AI Skill。仅负责商品创建(手动一
|
|
|
112
112
|
| `need_brand_create` | 品牌未找到,需去 Seller Center 处理 | 先引导查看已有品牌及审核状态(https://sellercenter.miravia.es/apps/setting/seller/brandlist,可能已在审核中),确无再创建(https://sellercenter.miravia.es/apps/product/publish/brand);创建后重新运行命令 |
|
|
113
113
|
| `need_mandatory_fields` | 缺少必填字段;**图片缺失为硬阻断(不可 Draft)**,价格/重量/尺寸缺失可 `--draft` | 补充字段后重跑 |
|
|
114
114
|
| `need_category_attributes` | 类目必填属性缺失(AI抽取模式) | 通过 `--attribute-data` 补充或使用 `--draft` |
|
|
115
|
+
| `need_attribute_mapping` | Excel/CSV 列未能自动匹配到类目属性(不静默丢弃),返回该列及其候选属性 | 向用户展示候选属性,确认后用 `--attribute-data '{"<属性名>":"<值>"}'` 重跑;非商品属性则删除该列 |
|
|
115
116
|
| `need_field_summary` | 需要确认字段摘要或选择草稿模式 | 回传 `field_summary_token`,可加 `--draft` |
|
|
116
117
|
| `need_compliance` | 制造商/EU 负责人匹配失败 | 按返回候选选择或补充信息后重跑 |
|
|
117
118
|
| `compliance_missing_suggest_draft` | 合规字段缺失(制造商/欧代/Safety Warning内容/合规文档) | 补充合规字段或加 `--draft` 发布为草稿 |
|
package/dist/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.8
|
|
@@ -371,6 +371,7 @@ _TABLE_FIELD_SYNONYMS: Dict[str, tuple] = {
|
|
|
371
371
|
"length_cm": ("长度", "长cm", "长", "length", "largura"),
|
|
372
372
|
"width_cm": ("宽度", "宽cm", "宽", "width", "ancho"),
|
|
373
373
|
"height_cm": ("高度", "高cm", "高", "height", "altura"),
|
|
374
|
+
"delivery_method": ("deliverymethod", "delivery", "shippingmethod", "shipping", "物流方式", "物流", "发货方式", "配送方式", "metododeenvío", "metododeenvio", "envío", "envio", "spedizione", "metododiconsegna"),
|
|
374
375
|
"manufacturer_name": ("制造商", "厂家", "manufacturer", "fabricante"),
|
|
375
376
|
"eu_responsible_person_name": ("欧盟负责人", "欧代", "euresponsibleperson", "eurp"),
|
|
376
377
|
"variation_1": ("颜色", "color", "colour", "variation1", "variation", "款式"),
|
|
@@ -389,6 +390,7 @@ _PRODUCT_LEVEL_FIELDS = {
|
|
|
389
390
|
"length_cm",
|
|
390
391
|
"width_cm",
|
|
391
392
|
"height_cm",
|
|
393
|
+
"delivery_method",
|
|
392
394
|
"manufacturer_name",
|
|
393
395
|
"eu_responsible_person_name",
|
|
394
396
|
}
|
package/dist/scripts/i18n.py
CHANGED
|
@@ -249,6 +249,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
249
249
|
"pub_dbm_dim_sum_exceeded": "Dimension sum {dim_sum} exceeds DBM limit {limit}cm.",
|
|
250
250
|
"pub_dbm_volume_exceeded": "Volumetric weight {volume} exceeds DBM limit {limit}.",
|
|
251
251
|
"pub_dbm_auto_switch_to_dbs": "Package exceeds DBM limits. Delivery method auto-switched to DBS.",
|
|
252
|
+
"pub_dbs_no_permission_downgrade": "Seller requested DBS but this account has no DBS permission; the delivery method was set to DBM.",
|
|
252
253
|
"pub_dbm_no_dbs_permission": "Package exceeds DBM limits but seller has no DBS permission. Cannot auto-switch to DBS.",
|
|
253
254
|
"pub_dbm_fix_or_apply_dbs": "Please reduce package size/weight to meet DBM limits, or apply for DBS permission in Seller Center.",
|
|
254
255
|
# SKU already exists (concurrency conflict)
|
|
@@ -338,6 +339,9 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
338
339
|
"pub_need_category_attributes_description": "The category {category_id} requires the following attributes that were not found in the extracted data. Please provide them via --attribute-data or rerun with --draft to publish as an inactive draft.",
|
|
339
340
|
"pub_need_category_attributes_item": "{idx}. {label} ({en_name}) - {input_type}",
|
|
340
341
|
"pub_need_category_attributes_next": "Provide the missing attributes and rerun with --attribute-data '<json>', or use --draft to publish as inactive draft and complete them later.",
|
|
342
|
+
"pub_need_attribute_mapping_title": "Some columns could not be matched automatically",
|
|
343
|
+
"pub_need_attribute_mapping_description": "These Excel/CSV columns did not clearly match a category attribute, so they were not bound. Please confirm which attribute each column should map to, then rerun with --attribute-data using the canonical attribute names shown below (or drop the column if it is not a product attribute).",
|
|
344
|
+
"pub_need_attribute_mapping_next": "Rerun with --attribute-data '{\"<attribute_name>\": \"<value>\"}' using one of the suggested attribute names, or remove the column if it is not needed.",
|
|
341
345
|
"pub_html_cleaned_notice": "The product description has been cleaned: unsafe HTML tags (script, iframe, style attributes, etc.) were removed. Only safe formatting tags (p, br, b, ul, li, etc.) are kept.",
|
|
342
346
|
"pub_create_pending_review": "Product submitted successfully. It is currently under Miravia review and temporarily unavailable for sale; approval may take up to 48 hours, especially if package photos or documents are required. Once approved, it will be activated automatically. If rejected, please check the Suspended tab.",
|
|
343
347
|
"pub_pdp_link": "Product detail page: {url}",
|
|
@@ -914,6 +918,7 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
914
918
|
"pub_dbm_dim_sum_exceeded": "三边之和 {dim_sum} 超过 DBM 限制 {limit}cm。",
|
|
915
919
|
"pub_dbm_volume_exceeded": "体积重 {volume} 超过 DBM 限制 {limit}。",
|
|
916
920
|
"pub_dbm_auto_switch_to_dbs": "包裹超出 DBM 限制,物流方式自动转为 DBS。",
|
|
921
|
+
"pub_dbs_no_permission_downgrade": "商家请求使用 DBS,但当前账号没有 DBS 权限,物流方式已设为 DBM。",
|
|
917
922
|
"pub_dbm_no_dbs_permission": "包裹超出 DBM 限制,但卖家无 DBS 权限,无法自动转为 DBS。",
|
|
918
923
|
"pub_dbm_fix_or_apply_dbs": "请减小包裹尺寸/重量以满足 DBM 限制,或在 Seller Center 申请 DBS 权限。",
|
|
919
924
|
# SKU already exists (concurrency conflict)
|
|
@@ -968,6 +973,9 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
968
973
|
"pub_need_category_attributes_description": "类目 {category_id} 要求以下属性,但 AI 抽取的数据中未包含。请通过 --attribute-data 补充,或使用 --draft 发布为未激活草稿。",
|
|
969
974
|
"pub_need_category_attributes_item": "{idx}. {label}({en_name})- {input_type}",
|
|
970
975
|
"pub_need_category_attributes_next": "补充缺失属性后使用 --attribute-data '<json>' 重新运行,或使用 --draft 发布为未激活草稿稍后补充。",
|
|
976
|
+
"pub_need_attribute_mapping_title": "部分列未能自动匹配",
|
|
977
|
+
"pub_need_attribute_mapping_description": "以下 Excel/CSV 列未能明确匹配到类目属性,因此未绑定。请确认每列应对应哪个属性,然后用下方展示的规范属性名通过 --attribute-data 重新运行(若该列不是商品属性则可删除)。",
|
|
978
|
+
"pub_need_attribute_mapping_next": "使用下方建议的属性名之一,通过 --attribute-data '{\"<属性名>\": \"<值>\"}' 重新运行;若不需要该列则删除。",
|
|
971
979
|
"pub_html_cleaned_notice": "商品描述已清理:不安全的 HTML 标签(script、iframe、style 属性等)已被移除。仅保留安全格式标签(p、br、b、ul、li 等)。",
|
|
972
980
|
"pub_create_pending_review": "商品提交成功,目前正在接受 Miravia 审核,暂不可售;审核最长可能需要 48 小时,特别是需要包装照片或文件时。审核通过后会自动激活,如被拒绝请在“已暂停(Suspended)”选项卡中查看。",
|
|
973
981
|
"pub_pdp_link": "商品详情页:{url}",
|
|
@@ -365,8 +365,10 @@ class ProductClient:
|
|
|
365
365
|
# when absent, publish_module._check_dbm_limits applies the default
|
|
366
366
|
# (DBM generally, DBS if the seller has DBS permission).
|
|
367
367
|
_dm = str(data.get("delivery_method") or "").strip().upper()
|
|
368
|
-
if
|
|
369
|
-
attributes["deliverySof"] =
|
|
368
|
+
if "DBS" in _dm:
|
|
369
|
+
attributes["deliverySof"] = "DBS"
|
|
370
|
+
elif "DBM" in _dm:
|
|
371
|
+
attributes["deliverySof"] = "DBM"
|
|
370
372
|
|
|
371
373
|
product_payload: Dict[str, Any] = {
|
|
372
374
|
"PrimaryCategory": category_id,
|
|
@@ -373,6 +373,25 @@ def _print_conversational_result(result: Dict[str, Any]) -> None:
|
|
|
373
373
|
print(f"• {action}")
|
|
374
374
|
return
|
|
375
375
|
|
|
376
|
+
# Unmapped Excel/CSV columns needing a confirmed attribute mapping
|
|
377
|
+
if status == "need_attribute_mapping":
|
|
378
|
+
print(result.get("title", ""))
|
|
379
|
+
print(result.get("message", ""))
|
|
380
|
+
print()
|
|
381
|
+
for item in result.get("unmapped_columns", []):
|
|
382
|
+
print(f" 列 \"{item.get('header')}\" = {item.get('value')} 可能对应:")
|
|
383
|
+
for c in item.get("candidates", []):
|
|
384
|
+
mand = "(必填)" if str(c.get("is_mandatory")) == "1" else ""
|
|
385
|
+
print(
|
|
386
|
+
f" - {c.get('label')} "
|
|
387
|
+
f"[name={c.get('name')}, en_name={c.get('en_name')}, "
|
|
388
|
+
f"type={c.get('attribute_type')}]{mand}"
|
|
389
|
+
)
|
|
390
|
+
print()
|
|
391
|
+
for action in result.get("next_actions", []):
|
|
392
|
+
print(f"• {action}")
|
|
393
|
+
return
|
|
394
|
+
|
|
376
395
|
if status == "need_images":
|
|
377
396
|
print(result.get("message", ""))
|
|
378
397
|
if result.get("failed_images"):
|
|
@@ -813,12 +813,20 @@ class PublishModule:
|
|
|
813
813
|
"""
|
|
814
814
|
attrs = product_payload.setdefault("Attributes", {})
|
|
815
815
|
delivery_method = str(attrs.get("deliverySof") or "").strip().upper()
|
|
816
|
+
downgraded_from_dbs = False
|
|
816
817
|
|
|
817
|
-
# Default when there is no explicit entry: DBS if the seller is
|
|
818
|
-
# permitted, otherwise DBM.
|
|
819
818
|
if delivery_method not in ("DBM", "DBS"):
|
|
819
|
+
# No explicit entry: default to DBS when the seller is permitted,
|
|
820
|
+
# otherwise DBM.
|
|
820
821
|
delivery_method = "DBS" if has_dbs_permission else "DBM"
|
|
821
822
|
attrs["deliverySof"] = delivery_method
|
|
823
|
+
elif delivery_method == "DBS" and not has_dbs_permission:
|
|
824
|
+
# Seller explicitly chose DBS but the account has no DBS permission:
|
|
825
|
+
# never grant DBS without permission — fall back to DBM and still
|
|
826
|
+
# enforce the DBM package limits below.
|
|
827
|
+
delivery_method = "DBM"
|
|
828
|
+
attrs["deliverySof"] = "DBM"
|
|
829
|
+
downgraded_from_dbs = True
|
|
822
830
|
|
|
823
831
|
# Only DBM carries package limits.
|
|
824
832
|
if delivery_method != "DBM":
|
|
@@ -856,6 +864,11 @@ class PublishModule:
|
|
|
856
864
|
)
|
|
857
865
|
|
|
858
866
|
if not exceeded:
|
|
867
|
+
if downgraded_from_dbs:
|
|
868
|
+
return {
|
|
869
|
+
"downgraded_to_dbm": True,
|
|
870
|
+
"message": i18n._("pub_dbs_no_permission_downgrade"),
|
|
871
|
+
}
|
|
859
872
|
return None
|
|
860
873
|
|
|
861
874
|
# Exceeded DBM limits — check DBS permission
|
|
@@ -907,6 +920,7 @@ class PublishModule:
|
|
|
907
920
|
# Check DBM limits — auto-switch to DBS if exceeded (requires DBS permission)
|
|
908
921
|
has_dbs = self.product.get_shipping_permission()
|
|
909
922
|
dbm_result = self._check_dbm_limits(product_payload, has_dbs_permission=has_dbs)
|
|
923
|
+
dbm_notes: List[str] = []
|
|
910
924
|
if dbm_result:
|
|
911
925
|
if dbm_result.get("blocked"):
|
|
912
926
|
# No DBS permission and exceeds DBM limits — block
|
|
@@ -919,10 +933,12 @@ class PublishModule:
|
|
|
919
933
|
],
|
|
920
934
|
}
|
|
921
935
|
if dbm_result.get("switched_to_dbs"):
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
936
|
+
dbm_notes.append(dbm_result["message"])
|
|
937
|
+
dbm_notes.extend(f" - {r}" for r in dbm_result.get("reasons", []))
|
|
938
|
+
if dbm_result.get("downgraded_to_dbm"):
|
|
939
|
+
# Seller asked for DBS but has no DBS permission — explain the
|
|
940
|
+
# fallback so the delivery-method change is not silent.
|
|
941
|
+
dbm_notes.append(dbm_result["message"])
|
|
926
942
|
|
|
927
943
|
# Run pre-validate early so errors are visible in the preview
|
|
928
944
|
prevalidate_result = self._run_prevalidate(product_payload)
|
|
@@ -935,6 +951,7 @@ class PublishModule:
|
|
|
935
951
|
|
|
936
952
|
# Merge all issues for preview
|
|
937
953
|
all_issues = list(draft_issues or [])
|
|
954
|
+
all_issues.extend(dbm_notes)
|
|
938
955
|
if prevalidate_errors:
|
|
939
956
|
all_issues.extend(prevalidate_errors)
|
|
940
957
|
|
|
@@ -1340,11 +1357,25 @@ class PublishModule:
|
|
|
1340
1357
|
draft=draft_requested,
|
|
1341
1358
|
)
|
|
1342
1359
|
|
|
1360
|
+
# Explicit --attribute-data must reach the AI payload too (it used to be
|
|
1361
|
+
# honored only in the manual flow, so AI-mode values were silently
|
|
1362
|
+
# dropped). Applied before unmatched-column binding so explicit values win.
|
|
1363
|
+
self._apply_attribute_data_override(product_payload)
|
|
1364
|
+
|
|
1343
1365
|
# Bind extra Excel/CSV columns that the whitelist did not recognize,
|
|
1344
1366
|
# using the category attribute definitions fetched above.
|
|
1345
|
-
|
|
1367
|
+
unmapped_cols = self._apply_unmatched_category_attributes(
|
|
1346
1368
|
product_payload, schema, category_attributes
|
|
1347
1369
|
)
|
|
1370
|
+
self._unmapped_columns = unmapped_cols
|
|
1371
|
+
|
|
1372
|
+
# If a provided column could not be confidently matched but plausibly
|
|
1373
|
+
# maps to one or more category attributes, ask the seller to confirm the
|
|
1374
|
+
# mapping instead of silently dropping it (which would otherwise surface
|
|
1375
|
+
# later as a cryptic batchValidate "missing attribute" error).
|
|
1376
|
+
mapping_check = self._check_unmapped_columns(unmapped_cols, product_payload)
|
|
1377
|
+
if mapping_check:
|
|
1378
|
+
return mapping_check
|
|
1348
1379
|
|
|
1349
1380
|
# Stage 3b: unified compliance check (GPSR + Safety Warning + compliance docs)
|
|
1350
1381
|
draft_issues: list = []
|
|
@@ -1672,15 +1703,19 @@ class PublishModule:
|
|
|
1672
1703
|
"""
|
|
1673
1704
|
confirmed_brand_id = getattr(self.args, "confirmed_brand_id", None)
|
|
1674
1705
|
if confirmed_brand_id:
|
|
1706
|
+
# Honor an explicitly passed --brand-name too (aligns with the
|
|
1707
|
+
# manual flow's _resolve_brand_interactive). Otherwise a seller who
|
|
1708
|
+
# confirms by ID but whose source lacks the brand name would have
|
|
1709
|
+
# the brand silently dropped from the payload.
|
|
1675
1710
|
return {
|
|
1676
1711
|
"brand_id": int(confirmed_brand_id),
|
|
1677
|
-
"brand_name": schema.brand_name or "",
|
|
1712
|
+
"brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
|
|
1678
1713
|
}
|
|
1679
1714
|
|
|
1680
1715
|
if schema.brand_id:
|
|
1681
1716
|
return {
|
|
1682
1717
|
"brand_id": int(schema.brand_id),
|
|
1683
|
-
"brand_name": schema.brand_name or "",
|
|
1718
|
+
"brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
|
|
1684
1719
|
}
|
|
1685
1720
|
|
|
1686
1721
|
brand_name = self.args.brand_name or schema.brand_name
|
|
@@ -2179,8 +2214,9 @@ class PublishModule:
|
|
|
2179
2214
|
value under the attribute's canonical key — into each SKU for
|
|
2180
2215
|
``attribute_type == 'sku'``, otherwise into the product-level
|
|
2181
2216
|
``Attributes``. Existing keys are never overwritten (whitelist/LLM
|
|
2182
|
-
values win). Returns
|
|
2183
|
-
|
|
2217
|
+
values win). Returns a list of ``{header, value, candidates}`` for the
|
|
2218
|
+
headers that matched no category attribute confidently, so the caller can
|
|
2219
|
+
ask the seller to confirm the mapping instead of silently dropping them.
|
|
2184
2220
|
|
|
2185
2221
|
We intentionally bind ONLY the non-whitelisted columns here. Columns
|
|
2186
2222
|
the whitelist already consumed (Title/Price/Image/Color/Stock/...) are
|
|
@@ -2196,11 +2232,31 @@ class PublishModule:
|
|
|
2196
2232
|
return []
|
|
2197
2233
|
attributes = product_payload.setdefault("Attributes", {})
|
|
2198
2234
|
skus = (product_payload.get("Skus") or {}).get("Sku") or []
|
|
2199
|
-
unmapped: List[str] = []
|
|
2235
|
+
unmapped: List[Dict[str, Any]] = []
|
|
2200
2236
|
for header, value in unmatched.items():
|
|
2201
2237
|
attr = self._find_category_attribute(category_attributes, (header,))
|
|
2202
2238
|
if not attr:
|
|
2203
|
-
|
|
2239
|
+
# No confident match: collect the header with fuzzy candidates so
|
|
2240
|
+
# the caller can ask the seller to confirm the intended attribute
|
|
2241
|
+
# instead of silently dropping the column.
|
|
2242
|
+
unmapped.append(
|
|
2243
|
+
{
|
|
2244
|
+
"header": header,
|
|
2245
|
+
"value": value,
|
|
2246
|
+
"candidates": [
|
|
2247
|
+
{
|
|
2248
|
+
"name": c.get("name"),
|
|
2249
|
+
"en_name": c.get("en_name"),
|
|
2250
|
+
"label": c.get("label") or c.get("en_name") or c.get("name"),
|
|
2251
|
+
"attribute_type": c.get("attribute_type") or "normal",
|
|
2252
|
+
"is_mandatory": str(c.get("is_mandatory", "0")),
|
|
2253
|
+
}
|
|
2254
|
+
for c in self._suggest_attribute_candidates(
|
|
2255
|
+
header, category_attributes
|
|
2256
|
+
)
|
|
2257
|
+
],
|
|
2258
|
+
}
|
|
2259
|
+
)
|
|
2204
2260
|
continue
|
|
2205
2261
|
key = attr.get("name") or attr.get("en_name") or header
|
|
2206
2262
|
if (attr.get("attribute_type") or "normal") == "sku":
|
|
@@ -2212,11 +2268,122 @@ class PublishModule:
|
|
|
2212
2268
|
attributes[key] = value
|
|
2213
2269
|
if unmapped:
|
|
2214
2270
|
logger.info(
|
|
2215
|
-
"Excel/CSV columns not
|
|
2216
|
-
", ".join(unmapped),
|
|
2271
|
+
"Excel/CSV columns not confidently matched: %s",
|
|
2272
|
+
", ".join(u["header"] for u in unmapped),
|
|
2217
2273
|
)
|
|
2218
2274
|
return unmapped
|
|
2219
2275
|
|
|
2276
|
+
def _suggest_attribute_candidates(
|
|
2277
|
+
self,
|
|
2278
|
+
header: str,
|
|
2279
|
+
category_attributes: List[Dict[str, Any]],
|
|
2280
|
+
max_candidates: int = 8,
|
|
2281
|
+
) -> List[Dict[str, Any]]:
|
|
2282
|
+
"""Fuzzy-suggest category attributes a free-text column header might map
|
|
2283
|
+
to — for the confirmation prompt only, never auto-bound.
|
|
2284
|
+
|
|
2285
|
+
Uses a shared-prefix (stem) heuristic so e.g. header ``packaging``
|
|
2286
|
+
suggests ``Package_Label_Photo_EU``. Deliberately looser than
|
|
2287
|
+
``_find_category_attribute`` because a human confirms the result.
|
|
2288
|
+
"""
|
|
2289
|
+
def _words(text: Any) -> List[str]:
|
|
2290
|
+
return [w for w in re.split(r"[^a-z0-9]+", str(text).lower()) if len(w) >= 4]
|
|
2291
|
+
|
|
2292
|
+
header_words = set(_words(header))
|
|
2293
|
+
h_sq = self._squash_attr_name(header)
|
|
2294
|
+
if h_sq:
|
|
2295
|
+
header_words.add(h_sq)
|
|
2296
|
+
out: List[Dict[str, Any]] = []
|
|
2297
|
+
for attr in category_attributes or []:
|
|
2298
|
+
attr_words: List[str] = []
|
|
2299
|
+
for key in ("name", "en_name", "label", "attribute_name"):
|
|
2300
|
+
if attr.get(key):
|
|
2301
|
+
attr_words.extend(_words(attr.get(key)))
|
|
2302
|
+
attr_words.append(self._squash_attr_name(attr.get(key)))
|
|
2303
|
+
hit = False
|
|
2304
|
+
for hw in header_words:
|
|
2305
|
+
for aw in attr_words:
|
|
2306
|
+
if (
|
|
2307
|
+
len(hw) >= 5
|
|
2308
|
+
and len(aw) >= 5
|
|
2309
|
+
and hw[:5] == aw[:5]
|
|
2310
|
+
) or hw in aw or aw in hw:
|
|
2311
|
+
hit = True
|
|
2312
|
+
break
|
|
2313
|
+
if hit:
|
|
2314
|
+
break
|
|
2315
|
+
if hit:
|
|
2316
|
+
out.append(attr)
|
|
2317
|
+
if len(out) >= max_candidates:
|
|
2318
|
+
break
|
|
2319
|
+
return out
|
|
2320
|
+
|
|
2321
|
+
def _apply_attribute_data_override(self, product_payload: Dict[str, Any]) -> None:
|
|
2322
|
+
"""Merge ``--attribute-data`` into the AI-built payload.
|
|
2323
|
+
|
|
2324
|
+
The AI flow builds Attributes/Skus from the extracted schema and never
|
|
2325
|
+
consulted ``--attribute-data``, so values passed there were silently
|
|
2326
|
+
dropped. ``--attribute-data`` is the seller's own explicit input, so it
|
|
2327
|
+
overrides extracted values; SKU-level keys are merged into every SKU.
|
|
2328
|
+
"""
|
|
2329
|
+
raw = getattr(self.args, "attribute_data", None)
|
|
2330
|
+
if not raw:
|
|
2331
|
+
return
|
|
2332
|
+
try:
|
|
2333
|
+
data = json.loads(raw)
|
|
2334
|
+
except json.JSONDecodeError as exc:
|
|
2335
|
+
raise ValueError(f"--attribute-data is not valid JSON: {exc}") from exc
|
|
2336
|
+
if not isinstance(data, dict):
|
|
2337
|
+
raise ValueError("--attribute-data must be a JSON object")
|
|
2338
|
+
attributes = product_payload.setdefault("Attributes", {})
|
|
2339
|
+
skus = (product_payload.get("Skus") or {}).get("Sku") or []
|
|
2340
|
+
for key, value in data.items():
|
|
2341
|
+
if key in _SKU_LEVEL_ATTRIBUTE_NAMES:
|
|
2342
|
+
for sku in skus:
|
|
2343
|
+
sku[key] = value
|
|
2344
|
+
else:
|
|
2345
|
+
attributes[key] = value
|
|
2346
|
+
|
|
2347
|
+
def _check_unmapped_columns(
|
|
2348
|
+
self,
|
|
2349
|
+
unmapped_cols: List[Dict[str, Any]],
|
|
2350
|
+
product_payload: Dict[str, Any],
|
|
2351
|
+
) -> Optional[Dict[str, Any]]:
|
|
2352
|
+
"""Return a ``need_attribute_mapping`` prompt when an unmapped column
|
|
2353
|
+
looks like a category attribute the seller intended, so they confirm the
|
|
2354
|
+
mapping instead of us dropping it silently (which would later surface as
|
|
2355
|
+
a cryptic batchValidate "missing attribute" error). Columns with no
|
|
2356
|
+
plausible candidate — or whose candidates are already filled via
|
|
2357
|
+
``--attribute-data`` — are ignored.
|
|
2358
|
+
"""
|
|
2359
|
+
attributes = product_payload.get("Attributes") or {}
|
|
2360
|
+
skus = (product_payload.get("Skus") or {}).get("Sku") or []
|
|
2361
|
+
|
|
2362
|
+
def _filled(cand: Dict[str, Any]) -> bool:
|
|
2363
|
+
names = [cand.get("name"), cand.get("en_name")]
|
|
2364
|
+
|
|
2365
|
+
def _present(container: Dict[str, Any]) -> bool:
|
|
2366
|
+
return any(n and str(container.get(n) or "").strip() for n in names)
|
|
2367
|
+
|
|
2368
|
+
if (cand.get("attribute_type") or "normal") == "sku":
|
|
2369
|
+
return bool(skus) and all(_present(s) for s in skus)
|
|
2370
|
+
return _present(attributes)
|
|
2371
|
+
|
|
2372
|
+
need: List[Dict[str, Any]] = []
|
|
2373
|
+
for item in unmapped_cols or []:
|
|
2374
|
+
open_cands = [c for c in (item.get("candidates") or []) if not _filled(c)]
|
|
2375
|
+
if open_cands:
|
|
2376
|
+
need.append({**item, "candidates": open_cands})
|
|
2377
|
+
if not need:
|
|
2378
|
+
return None
|
|
2379
|
+
return {
|
|
2380
|
+
"status": "need_attribute_mapping",
|
|
2381
|
+
"title": i18n._("pub_need_attribute_mapping_title"),
|
|
2382
|
+
"message": i18n._("pub_need_attribute_mapping_description"),
|
|
2383
|
+
"unmapped_columns": need,
|
|
2384
|
+
"next_actions": [i18n._("pub_need_attribute_mapping_next")],
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2220
2387
|
def _field_is_required(
|
|
2221
2388
|
self,
|
|
2222
2389
|
field_key: str,
|
|
@@ -2503,7 +2670,7 @@ class PublishModule:
|
|
|
2503
2670
|
confirmed_brand_id = getattr(self.args, "confirmed_brand_id", None)
|
|
2504
2671
|
if confirmed_brand_id:
|
|
2505
2672
|
return {
|
|
2506
|
-
"brand_name": schema.brand_name or "",
|
|
2673
|
+
"brand_name": getattr(self.args, "brand_name", None) or schema.brand_name or "",
|
|
2507
2674
|
"brand_id": int(confirmed_brand_id),
|
|
2508
2675
|
}
|
|
2509
2676
|
|
|
@@ -2642,7 +2809,11 @@ class PublishModule:
|
|
|
2642
2809
|
|
|
2643
2810
|
Per the Miravia ``/product/create`` documentation:
|
|
2644
2811
|
|
|
2645
|
-
- ``SellerSku`` is mandatory
|
|
2812
|
+
- ``SellerSku`` is NOT hard-coded mandatory: whether it must be filled is
|
|
2813
|
+
governed by the category attribute API and enforced once in
|
|
2814
|
+
:meth:`_validate_create_payload` (via ``seller_sku_required``), which
|
|
2815
|
+
applies to both Draft and online publishes. Here we simply omit an
|
|
2816
|
+
empty SellerSku so ``/product/create`` can auto-generate a value.
|
|
2646
2817
|
- Package dimensions/weight are per-SKU fields
|
|
2647
2818
|
(``package_length/width/height/weight``).
|
|
2648
2819
|
- Sales properties are flat SKU fields, not a nested ``Variation``
|
|
@@ -2671,11 +2842,11 @@ class PublishModule:
|
|
|
2671
2842
|
seller_sku = raw.get("seller_sku") or (
|
|
2672
2843
|
schema.seller_skus[idx] if idx < len(schema.seller_skus) else None
|
|
2673
2844
|
)
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2845
|
+
# Do NOT hard-require SellerSku here. Its mandatory-ness comes from
|
|
2846
|
+
# the category attribute API and is enforced in _validate_create_payload
|
|
2847
|
+
# (not relaxed by draft). Omit empty values so create can auto-generate.
|
|
2848
|
+
if seller_sku:
|
|
2849
|
+
sku["SellerSku"] = seller_sku
|
|
2679
2850
|
|
|
2680
2851
|
ean_code = raw.get("ean_code") or (
|
|
2681
2852
|
schema.ean_codes[idx] if idx < len(schema.ean_codes) else None
|
|
@@ -3186,10 +3357,11 @@ class PublishModule:
|
|
|
3186
3357
|
processed["SellerSku"] = processed.pop("seller_sku")
|
|
3187
3358
|
elif "seller_sku" in processed:
|
|
3188
3359
|
processed.pop("seller_sku") # SellerSku already set
|
|
3360
|
+
# SellerSku is optional (governed by the category attribute API and
|
|
3361
|
+
# enforced in _validate_create_payload, not relaxed by draft). When
|
|
3362
|
+
# empty, drop the key so /product/create can auto-generate it.
|
|
3189
3363
|
if not processed.get("SellerSku"):
|
|
3190
|
-
|
|
3191
|
-
i18n._("pub_seller_sku_required", idx=idx + 1)
|
|
3192
|
-
)
|
|
3364
|
+
processed.pop("SellerSku", None)
|
|
3193
3365
|
# Migrate SKU-level images
|
|
3194
3366
|
sku_images = processed.get("Images", {}).get("Image", [])
|
|
3195
3367
|
if sku_images:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miravia_test/miravia-publish-skill",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Miravia Product Publish Skill installer — one command to install the Miravia AI product listing skill into any AI Agent (Qoder / Cursor / Claude Code). Upload materials and let the AI extract data and publish products automatically.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"miravia-publish-skill": "bin/cli.js"
|