@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 CHANGED
@@ -24,9 +24,9 @@ description: Miravia 商品发布 AI Skill。仅负责商品创建(手动一
24
24
 
25
25
  | 文件 | 职责 |
26
26
  |------|------|
27
- | `scripts/modules/publish_module.py` | 发布流程主控(类目/品牌/属性/图片/SKU/合规/确认/创建) |
28
- | `scripts/modules/extraction_engine.py` | 从源文本抽取 `MiraviaListingSchema` |
29
- | `scripts/modules/file_reader.py` | 读取本地文件、文件夹或网页 URL |
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
- | `need_category` | 类目未找到或存在多个候选 | 让用户提供正确的类目 ID(`--category-id`)或从候选中选择一个(`--confirmed-category-id`),**禁止重复搜索同一关键词** |
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
- 只需在此处上传您产品的所有资料,例如上传源文件,或选择产品信息所在的文件夹位置,或输入产品信息的网页 URL,本技能将相应地提取信息并将产品发布到 Miravia。
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/modules/__init__.py
240
- scripts/modules/publish_module.py
241
- scripts/modules/extraction_engine.py
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` 环境变量,使 `modules/extraction_engine.py` 走 LLM 抽取;否则 fallback 到 heuristic 规则,准确度有限。
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.2
1
+ 0.1.3
@@ -243,7 +243,7 @@ PRD 中标记为新增 API,但当前 Miravia Open Platform 文档站点未上
243
243
 
244
244
  ## 附录 A. AI 发品字段映射表
245
245
 
246
- > 本附录说明 `modules/extraction_engine.MiraviaListingSchema` 与 Miravia `/product/create` 请求体之间的映射关系。抽取层使用 snake_case,API 层使用 camelCase / Miravia 原生字段名。
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="auto",
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
- prices = re.findall(r"(?:€|EUR|Euro|price)\s*[:=]?\s*(\d+(?:\.\d{1,2})?)", text, re.I)
228
- if prices:
229
- schema.general_price = prices[0]
230
- if len(prices) > 1:
231
- schema.discounted_price = prices[1]
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", ".txt", ".md", ".csv", ".xls", ".xlsx")
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
- return "\n\n".join(text_parts)
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
- return "\n\n".join(text_parts)
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
- doc = docx.Document(path)
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
- return "\n\n".join(paragraphs)
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
- if suffix in (".txt", ".md", "") or _guess_mime(path).startswith("text/"):
180
- return {"source": source, "type": "text", "content": _read_text(path)}
181
-
182
- # Fallback: try as text
183
- try:
184
- return {"source": source, "type": "text", "content": _read_text(path)}
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
- resp = requests.get(url, headers=headers, timeout=timeout)
198
- resp.raise_for_status()
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
- results.append(_read_url(src))
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