@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
package/dist/SKILL.md
CHANGED
|
@@ -24,9 +24,9 @@ description: Miravia 商品发布 AI Skill。仅负责商品创建(手动一
|
|
|
24
24
|
|
|
25
25
|
| 文件 | 职责 |
|
|
26
26
|
|------|------|
|
|
27
|
-
| `scripts/
|
|
28
|
-
| `scripts/
|
|
29
|
-
| `scripts/
|
|
27
|
+
| `scripts/publish_module.py` | 发布流程主控(类目/品牌/属性/图片/SKU/合规/确认/创建) |
|
|
28
|
+
| `scripts/extraction_engine.py` | 从源文本抽取 `MiraviaListingSchema` |
|
|
29
|
+
| `scripts/file_reader.py` | 读取本地文件、文件夹或网页 URL |
|
|
30
30
|
| `scripts/field_definitions.py` | 商品字段定义与术语说明,供 LLM 抽取与提示使用 |
|
|
31
31
|
| `scripts/product_client.py` | Miravia 商品/图片/类目/品牌/合规 API 封装 |
|
|
32
32
|
| `scripts/miravia_client.py` | 开放平台请求签名、重试、埋点 |
|
|
@@ -99,7 +99,8 @@ description: Miravia 商品发布 AI Skill。仅负责商品创建(手动一
|
|
|
99
99
|
| 状态 | 含义 | 处理方式 |
|
|
100
100
|
|------|------|----------|
|
|
101
101
|
| `need_authorization` | 需要卖家授权三项操作权限 | 卖家确认后加 `--authorized` 重新运行 |
|
|
102
|
-
| `
|
|
102
|
+
| `authorization_cancelled` | 卖家明确拒绝授权 | 提示需授权,重新上传文件将再次触发授权请求 |
|
|
103
|
+
| `need_category` | 类目未找到、类目 ID 无效、推荐多候选或预测失败 | 让用户提供正确的类目 ID(`--category-id`)或从候选中选择一个(`--confirmed-category-id`),**禁止重复搜索同一关键词**;所有 ID 均校验类目树;关键词搜索失败后调 `/product/category/recommend/get` 推荐,Top1 自动选中,TopN 展示候选,空结果展示全量类目树 |
|
|
103
104
|
| `need_brand` | 品牌未找到或存在多个候选 | 让用户提供正确的品牌名(`--brand-name`)或从候选中选择一个(`--confirmed-brand-id`),**禁止重复搜索同一关键词** |
|
|
104
105
|
| `need_ctb_confirm` | 需要确认类目/标题/品牌 | 回传 `ctb_confirm_token` |
|
|
105
106
|
| `need_brand_create` | 品牌未找到,需去 Seller Center 创建 | 创建后重新运行命令 |
|
|
@@ -107,7 +108,20 @@ description: Miravia 商品发布 AI Skill。仅负责商品创建(手动一
|
|
|
107
108
|
| `need_category_attributes` | 类目必填属性缺失(AI抽取模式) | 通过 `--attribute-data` 补充或使用 `--draft` |
|
|
108
109
|
| `need_field_summary` | 需要确认字段摘要或选择草稿模式 | 回传 `field_summary_token`,可加 `--draft` |
|
|
109
110
|
| `need_compliance` | 制造商/EU 负责人匹配失败 | 按返回候选选择或补充信息后重跑 |
|
|
111
|
+
| `compliance_missing_suggest_draft` | 合规字段缺失(制造商/欧代/Safety Warning内容/合规文档) | 补充合规字段或加 `--draft` 发布为草稿 |
|
|
112
|
+
| `gpsr_missing_suggest_draft` | GPSR 字段缺失(制造商和/或欧盟负责人为空) | 补充 GPSR 字段或加 `--draft` 发布为草稿 |
|
|
113
|
+
| `post_to_inactive_missing_suggest_draft` | 类目必填字段缺失(EAN) | 补充字段或加 `--draft` 发布为草稿 |
|
|
114
|
+
| `special_category_block` | 特殊类目规则阻断(PPU 值无效 / 图书类目 ISBN 缺失) | 修正错误后重跑,不可转 Draft |
|
|
115
|
+
| `special_category_suggest_draft` | 特殊类目字段缺失(PPU 缺失 / 食品属性缺失) | 补充字段或加 `--draft` 发布为草稿 |
|
|
116
|
+
| `dbm_limit_exceeded_no_dbs` | DBM 超限但无 DBS 权限(重量>40KG / 三边和>300cm / 体积重>40) | 减小包裹尺寸/重量或申请 DBS 权限 |
|
|
117
|
+
| `sku_already_exists` | 发布时 SKU 已被占用(并发冲突或 SKU 重复) | 刷新商品列表检查或修改 Seller SKU 后重跑 |
|
|
118
|
+
| `multiple_products_detected` | Excel 中包含多个商品(>1 行) | 展示商品列表,用户选择序号/all/cancel |
|
|
110
119
|
| `need_images` | 部分图片迁移失败 | 替换图片后重跑 |
|
|
120
|
+
| `unsupported_format` | 上传了不支持的文件格式(zip/txt/psd 等) | 提示仅支持 PDF/Word/Excel/Image/URL,允许重新上传 |
|
|
121
|
+
| `file_empty_or_corrupted` | 文件内容为空或损坏(0KB PDF / 乱码 Word) | 提示文件内容为空或损坏,允许重新上传 |
|
|
122
|
+
| `invalid_url` | 输入的 URL 无法抓取 | 提示检查 URL 后重试 |
|
|
123
|
+
| `title_invalid` | 商品标题不符合规范(过短/过长/词数不足) | 提示具体错误 + 标题格式建议(Brand+Category+Model+Features+Scenario),阻断后续流程,禁止转 Draft |
|
|
124
|
+
| `need_category_permission` | 卖家无该类目发布权限 | 询问是否以 Draft 模式发布(待审批通过后自动激活),提供类目权限申请链接 https://sellercenter.miravia.es/apps/setting/category;或更换类目后重跑 |
|
|
111
125
|
| `preview` | 最终发布预览 | 回传 `publish_preview_token` |
|
|
112
126
|
| `success` | 创建成功 | 返回 `item_id`、`pdp_url`、审核提示 |
|
|
113
127
|
| `brand_permission_failed` | 品牌权限不足/待审批 (BRAND_PENDING / BRAND_PERMISSION_DENIED) | 加 `--draft` 发布为草稿,或在 Seller Center 申请品牌权限后重跑 |
|
|
@@ -178,6 +192,7 @@ python scripts/product_skill.py --agent-type qoder --scenario create_product \
|
|
|
178
192
|
| `--sku-data` | 否 | — | SKU JSON 或文件路径 |
|
|
179
193
|
| `--draft` | 否 | false | 关键字段缺失时发布为 draft |
|
|
180
194
|
| `--authorized` | 否 | false | 卖家已确认授权,跳过授权步骤 |
|
|
195
|
+
| `--decline-auth` | 否 | false | 卖家明确拒绝授权,停止流程并提示需授权 |
|
|
181
196
|
| `--output-schema` | 否 | false | 仅输出抽取结构,不发布 |
|
|
182
197
|
| `--ctb-confirm-token` | 否 | — | CTB 阶段返回的 HMAC token |
|
|
183
198
|
| `--field-summary-token` | 否 | — | 字段摘要阶段返回的 HMAC token |
|
|
@@ -191,7 +206,7 @@ python scripts/product_skill.py --agent-type qoder --scenario create_product \
|
|
|
191
206
|
## 7. 首次激活问候语
|
|
192
207
|
|
|
193
208
|
```text
|
|
194
|
-
|
|
209
|
+
只需在此处上传您产品的资料,例如上传源文件,或选择产品信息所在的文件夹位置,或输入产品信息的网页 URL。支持格式:PDF、Word、Excel、图片、URL。本技能将相应地提取信息并将产品发布到 Miravia。注意:当前仅支持发布单个商品。
|
|
195
210
|
```
|
|
196
211
|
|
|
197
212
|
问候语支持 `zh` / `en` / `es` / `it`,见 `scripts/i18n.py` 中的 `pub_greeting`。
|
|
@@ -236,8 +251,7 @@ scripts/skill_keypair.py
|
|
|
236
251
|
scripts/skill_logger.py
|
|
237
252
|
scripts/telemetry.py
|
|
238
253
|
scripts/trace_logger.py
|
|
239
|
-
scripts/
|
|
240
|
-
scripts/
|
|
241
|
-
scripts/
|
|
242
|
-
scripts/modules/file_reader.py
|
|
254
|
+
scripts/publish_module.py
|
|
255
|
+
scripts/extraction_engine.py
|
|
256
|
+
scripts/file_reader.py
|
|
243
257
|
```
|
package/dist/TECHNICAL.md
CHANGED
|
@@ -120,7 +120,7 @@ zip -r miravia_product_listing.zip . \
|
|
|
120
120
|
A: 尚未完成授权,请先执行 `python scripts/auth_cli.py login`。
|
|
121
121
|
|
|
122
122
|
**Q: AI 抽取效果差?**
|
|
123
|
-
A: 配置 `OPENAI_API_KEY` 或 `DASHSCOPE_API_KEY` 环境变量,使 `
|
|
123
|
+
A: 配置 `OPENAI_API_KEY` 或 `DASHSCOPE_API_KEY` 环境变量,使 `extraction_engine.py` 走 LLM 抽取;否则 fallback 到 heuristic 规则,准确度有限。
|
|
124
124
|
|
|
125
125
|
**Q: 品牌未找到怎么办?**
|
|
126
126
|
A: Skill 会返回 `need_brand_create` 状态与 Seller Center 创建链接。卖家创建品牌后,重新运行相同命令即可继续。
|
package/dist/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.3
|
|
@@ -243,7 +243,7 @@ PRD 中标记为新增 API,但当前 Miravia Open Platform 文档站点未上
|
|
|
243
243
|
|
|
244
244
|
## 附录 A. AI 发品字段映射表
|
|
245
245
|
|
|
246
|
-
> 本附录说明 `
|
|
246
|
+
> 本附录说明 `extraction_engine.MiraviaListingSchema` 与 Miravia `/product/create` 请求体之间的映射关系。抽取层使用 snake_case,API 层使用 camelCase / Miravia 原生字段名。
|
|
247
247
|
|
|
248
248
|
### A.1 顶层字段
|
|
249
249
|
|
|
@@ -43,8 +43,8 @@ def add_common_args(parser: argparse.ArgumentParser) -> None:
|
|
|
43
43
|
)
|
|
44
44
|
parser.add_argument(
|
|
45
45
|
"--lang",
|
|
46
|
-
default="
|
|
47
|
-
help="language: en | es | it | zh",
|
|
46
|
+
default="es",
|
|
47
|
+
help="language: en | es | it | zh (default: es for Miravia Spain)",
|
|
48
48
|
)
|
|
49
49
|
parser.add_argument(
|
|
50
50
|
"--format",
|
|
@@ -223,12 +223,21 @@ class ExtractionEngine:
|
|
|
223
223
|
r"https?://[^\s\"]+\.(?:jpg|jpeg|png|webp)", text, re.IGNORECASE
|
|
224
224
|
)
|
|
225
225
|
|
|
226
|
-
# Prices
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
# Prices - handle currency symbols before or after the number
|
|
227
|
+
# Matches: €10.00, $10, 10 EUR, 10 USD, price: 10.00, price=10
|
|
228
|
+
prices = re.findall(
|
|
229
|
+
r"(?:€|\$|EUR|Euro|USD|price)\s*[:=]?\s*(\d+(?:\.\d{1,2})?)"
|
|
230
|
+
r"|"
|
|
231
|
+
r"(\d+(?:\.\d{1,2})?)\s*(?:EUR|Euro|USD)",
|
|
232
|
+
text,
|
|
233
|
+
re.I,
|
|
234
|
+
)
|
|
235
|
+
# Flatten tuples and filter empty strings
|
|
236
|
+
cleaned_prices = [p for match in prices for p in match if p]
|
|
237
|
+
if cleaned_prices:
|
|
238
|
+
schema.general_price = cleaned_prices[0]
|
|
239
|
+
if len(cleaned_prices) > 1:
|
|
240
|
+
schema.discounted_price = cleaned_prices[1]
|
|
232
241
|
|
|
233
242
|
# Stock
|
|
234
243
|
stock_match = re.search(r"(?:stock|quantity)\s*[:=]?\s*(\d+)", text, re.I)
|
|
@@ -23,7 +23,13 @@ logger = logging.getLogger("miravia.product.file_reader")
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
SUPPORTED_IMAGE_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp")
|
|
26
|
-
SUPPORTED_DOC_SUFFIXES = (".pdf", ".doc", ".docx", ".
|
|
26
|
+
SUPPORTED_DOC_SUFFIXES = (".pdf", ".doc", ".docx", ".xls", ".xlsx")
|
|
27
|
+
|
|
28
|
+
# Formats that are explicitly blocked with a clear error message.
|
|
29
|
+
# .txt is intentionally excluded from supported formats per TC_P2:
|
|
30
|
+
# only PDF / Word / Excel / Image / URL are allowed.
|
|
31
|
+
UNSUPPORTED_SUFFIXES = (".zip", ".txt", ".psd", ".rar", ".7z", ".tar", ".gz",
|
|
32
|
+
".bmp", ".gif", ".tiff", ".svg", ".mp4", ".avi", ".mov")
|
|
27
33
|
|
|
28
34
|
|
|
29
35
|
def _guess_mime(path: Path) -> str:
|
|
@@ -51,6 +57,10 @@ def _read_text(path: Path) -> str:
|
|
|
51
57
|
|
|
52
58
|
|
|
53
59
|
def _read_pdf(path: Path) -> str:
|
|
60
|
+
# Detect empty / 0KB files before attempting to parse
|
|
61
|
+
if path.stat().st_size == 0:
|
|
62
|
+
raise ValueError(f"File is empty (0 bytes): {path}")
|
|
63
|
+
|
|
54
64
|
text_parts: List[str] = []
|
|
55
65
|
|
|
56
66
|
try:
|
|
@@ -61,7 +71,10 @@ def _read_pdf(path: Path) -> str:
|
|
|
61
71
|
page_text = page.extract_text()
|
|
62
72
|
if page_text:
|
|
63
73
|
text_parts.append(page_text)
|
|
64
|
-
|
|
74
|
+
result = "\n\n".join(text_parts)
|
|
75
|
+
if not result.strip():
|
|
76
|
+
raise ValueError(f"File content is empty or corrupted: {path}")
|
|
77
|
+
return result
|
|
65
78
|
except ImportError:
|
|
66
79
|
pass
|
|
67
80
|
|
|
@@ -72,7 +85,10 @@ def _read_pdf(path: Path) -> str:
|
|
|
72
85
|
reader = PyPDF2.PdfReader(f)
|
|
73
86
|
for page in reader.pages:
|
|
74
87
|
text_parts.append(page.extract_text() or "")
|
|
75
|
-
|
|
88
|
+
result = "\n\n".join(text_parts)
|
|
89
|
+
if not result.strip():
|
|
90
|
+
raise ValueError(f"File content is empty or corrupted: {path}")
|
|
91
|
+
return result
|
|
76
92
|
except ImportError:
|
|
77
93
|
raise RuntimeError(
|
|
78
94
|
"PDF reading requires pdfplumber or PyPDF2. Install one of them with:\n"
|
|
@@ -82,6 +98,10 @@ def _read_pdf(path: Path) -> str:
|
|
|
82
98
|
|
|
83
99
|
|
|
84
100
|
def _read_word(path: Path) -> str:
|
|
101
|
+
# Detect empty / 0KB files before attempting to parse
|
|
102
|
+
if path.stat().st_size == 0:
|
|
103
|
+
raise ValueError(f"File is empty (0 bytes): {path}")
|
|
104
|
+
|
|
85
105
|
try:
|
|
86
106
|
import docx
|
|
87
107
|
except ImportError:
|
|
@@ -90,9 +110,16 @@ def _read_word(path: Path) -> str:
|
|
|
90
110
|
" pip install python-docx"
|
|
91
111
|
)
|
|
92
112
|
|
|
93
|
-
|
|
113
|
+
try:
|
|
114
|
+
doc = docx.Document(path)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
raise ValueError(f"File content is empty or corrupted: {path} ({exc})")
|
|
117
|
+
|
|
94
118
|
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
|
95
|
-
|
|
119
|
+
result = "\n\n".join(paragraphs)
|
|
120
|
+
if not result.strip():
|
|
121
|
+
raise ValueError(f"File content is empty or corrupted: {path}")
|
|
122
|
+
return result
|
|
96
123
|
|
|
97
124
|
|
|
98
125
|
def _read_excel(path: Path) -> List[Dict[str, Any]]:
|
|
@@ -119,6 +146,27 @@ def _read_excel(path: Path) -> List[Dict[str, Any]]:
|
|
|
119
146
|
return result
|
|
120
147
|
|
|
121
148
|
|
|
149
|
+
def get_excel_row_count(content_items: List[Dict[str, Any]]) -> int:
|
|
150
|
+
"""Count total data rows across all Excel sheets in content items.
|
|
151
|
+
|
|
152
|
+
Returns 0 if no Excel/table data found.
|
|
153
|
+
"""
|
|
154
|
+
total = 0
|
|
155
|
+
for item in content_items:
|
|
156
|
+
if item.get("type") != "table":
|
|
157
|
+
continue
|
|
158
|
+
content = item.get("content")
|
|
159
|
+
if not content:
|
|
160
|
+
continue
|
|
161
|
+
tables = content if isinstance(content, list) else [content]
|
|
162
|
+
for table in tables:
|
|
163
|
+
if isinstance(table, dict) and "rows" in table:
|
|
164
|
+
total += len(table.get("rows", []))
|
|
165
|
+
elif isinstance(table, list):
|
|
166
|
+
total += len(table)
|
|
167
|
+
return total
|
|
168
|
+
|
|
169
|
+
|
|
122
170
|
def _read_csv(path: Path) -> List[Dict[str, str]]:
|
|
123
171
|
encodings = ["utf-8-sig", "utf-8", "gbk"]
|
|
124
172
|
for enc in encodings:
|
|
@@ -161,6 +209,13 @@ def _read_local_file(path: Path) -> Dict[str, Any]:
|
|
|
161
209
|
suffix = path.suffix.lower()
|
|
162
210
|
source = str(path)
|
|
163
211
|
|
|
212
|
+
# Block explicitly unsupported formats with a clear error
|
|
213
|
+
if suffix in UNSUPPORTED_SUFFIXES:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"Unsupported file format: {source}. "
|
|
216
|
+
"Only PDF, Word, Excel, Image, and URL are supported."
|
|
217
|
+
)
|
|
218
|
+
|
|
164
219
|
if _is_image(path):
|
|
165
220
|
return {"source": source, "type": "image", "content": _read_image(path)}
|
|
166
221
|
|
|
@@ -176,14 +231,12 @@ def _read_local_file(path: Path) -> Dict[str, Any]:
|
|
|
176
231
|
if suffix == ".csv":
|
|
177
232
|
return {"source": source, "type": "table", "content": _read_csv(path)}
|
|
178
233
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
except Exception as exc:
|
|
186
|
-
raise ValueError(f"Unsupported file type: {path} ({exc})")
|
|
234
|
+
# .txt, .md and other text-like formats are NOT supported per TC_P2.
|
|
235
|
+
# Only PDF / Word / Excel / Image / URL are allowed.
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"Unsupported file format: {source}. "
|
|
238
|
+
"Only PDF, Word, Excel, Image, and URL are supported."
|
|
239
|
+
)
|
|
187
240
|
|
|
188
241
|
|
|
189
242
|
def _read_url(url: str, timeout: int = 30) -> Dict[str, Any]:
|
|
@@ -194,8 +247,11 @@ def _read_url(url: str, timeout: int = 30) -> Dict[str, Any]:
|
|
|
194
247
|
"Chrome/120.0.0.0 Safari/537.36"
|
|
195
248
|
)
|
|
196
249
|
}
|
|
197
|
-
|
|
198
|
-
|
|
250
|
+
try:
|
|
251
|
+
resp = requests.get(url, headers=headers, timeout=timeout)
|
|
252
|
+
resp.raise_for_status()
|
|
253
|
+
except requests.RequestException as exc:
|
|
254
|
+
raise ValueError(f"Failed to fetch URL: {url} ({exc})")
|
|
199
255
|
|
|
200
256
|
content_type = resp.headers.get("Content-Type", "")
|
|
201
257
|
if "text/html" in content_type:
|
|
@@ -222,6 +278,9 @@ def read_sources(sources: List[str]) -> List[Dict[str, Any]]:
|
|
|
222
278
|
|
|
223
279
|
Returns a list of uniform content dicts:
|
|
224
280
|
``{"source": "...", "type": "text|table|image", "content": ...}``.
|
|
281
|
+
|
|
282
|
+
Raises ValueError for user-facing errors (unsupported format, empty file,
|
|
283
|
+
invalid URL) so the caller can surface a clear message to the user.
|
|
225
284
|
"""
|
|
226
285
|
results: List[Dict[str, Any]] = []
|
|
227
286
|
|
|
@@ -231,7 +290,12 @@ def read_sources(sources: List[str]) -> List[Dict[str, Any]]:
|
|
|
231
290
|
continue
|
|
232
291
|
|
|
233
292
|
if _is_url(src):
|
|
234
|
-
|
|
293
|
+
try:
|
|
294
|
+
results.append(_read_url(src))
|
|
295
|
+
except ValueError:
|
|
296
|
+
raise # Propagate user-facing URL errors
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
raise ValueError(f"Failed to fetch URL: {src} ({exc})")
|
|
235
299
|
continue
|
|
236
300
|
|
|
237
301
|
path = Path(src).expanduser()
|
|
@@ -252,11 +316,15 @@ def read_sources(sources: List[str]) -> List[Dict[str, Any]]:
|
|
|
252
316
|
for f in files:
|
|
253
317
|
try:
|
|
254
318
|
results.append(_read_local_file(f))
|
|
319
|
+
except ValueError:
|
|
320
|
+
raise # Propagate user-facing file errors
|
|
255
321
|
except Exception as exc:
|
|
256
322
|
logger.warning("Failed to read %s: %s", f, exc)
|
|
257
323
|
else:
|
|
258
324
|
try:
|
|
259
325
|
results.append(_read_local_file(path))
|
|
326
|
+
except ValueError:
|
|
327
|
+
raise # Propagate user-facing file errors
|
|
260
328
|
except Exception as exc:
|
|
261
329
|
logger.warning("Failed to read %s: %s", src, exc)
|
|
262
330
|
|