
Linkfox Amazon Store Aplus Content
- 184 installs
- 64 repo stars
- Updated August 3, 2026
- linkfox-ai/linkfox-skills
Helps with marketing & seo tasks.
About
linkfox-amazon-store-aplus-content is a Claude Code skill in the Marketing & SEO category.
- linkfox-amazon-store-aplus-content
- Marketing & SEO
- AI-coding skill
Linkfox Amazon Store Aplus Content by the numbers
- 184 all-time installs (skills.sh)
- +35 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #992 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/linkfox-ai/linkfox-skills --skill linkfox-amazon-store-aplus-contentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 184 |
|---|---|
| repo stars | ★ 64 |
| Last updated | August 3, 2026 |
| Repository | linkfox-ai/linkfox-skills ↗ |
What it does
Helps with marketing & seo tasks.
Files
Amazon 店铺 A+ Content Management
本 skill 与 `linkfox-amazon-store-auth`、`linkfox-amazon-store-listings` 等同属 Amazon Store 系列:使用 `POST /spApi/storeTokens` 取 accessToken,再经 `POST /spApi/developerProxy` 转发上游 GET 或 POST。
| 操作 | 官方参考 |
|---|---|
| 搜索文档列表 | searchContentDocuments |
| 新建文档 | createContentDocument |
| 获取文档(内容/元数据) | getContentDocument |
| 更新文档 | updateContentDocument |
| 列出 ASIN 关联 | listContentDocumentAsinRelations |
| 替换 ASIN 关联 | postContentDocumentAsinRelations |
| 校验文档与 ASIN | validateContentDocumentAsinRelations |
| 按 ASIN 查发布记录 | searchContentPublishRecords |
| 提交审核/发布 | postContentDocumentApprovalSubmission |
| 暂停前台展示 | postContentDocumentSuspendSubmission |
---
Prerequisites(必须先读)
本 skill 依赖 `linkfox-amazon-store-auth`。
1. 运行 python scripts/check_auth_dependency.py;若 exit code 42 且 stderr 含 DEPENDENCY_MISSING:,请先安装 `linkfox-amazon-store-auth`。 2. 不要在本 skill 内绕过依赖实现授权或令牌逻辑。
---
Current Capabilities
| 能力 | Path 要点 | 脚本 |
|---|---|---|
| 搜索 A+ 文档 | aplus/2020-11-01/contentDocuments + marketplaceId | scripts/search_content_documents.py |
| 创建 A+ 文档 | 同上 POST + body contentDocument | scripts/create_content_document.py |
| 获取单篇文档 | .../contentDocuments/{contentReferenceKey} + includedDataSet(必填,至少 1 项) | scripts/get_content_document.py |
| 更新文档 | 同上 POST + body | scripts/update_content_document.py |
| 列出关联 ASIN | .../contentDocuments/{key}/asins | scripts/list_content_document_asin_relations.py |
| 全量替换关联 ASIN | .../asins POST + body asinSet | scripts/post_content_document_asin_relations.py |
| 校验与 ASIN 的可用性 | .../contentAsinValidations POST | scripts/validate_content_document_asin_relations.py |
| 发布记录(按 ASIN) | .../contentPublishRecords + asin | scripts/search_content_publish_records.py |
| 提交审核 | .../approvalSubmissions POST | scripts/post_content_document_approval_submission.py |
| 暂停展示 | .../suspendSubmissions POST | scripts/post_content_document_suspend_submission.py |
注意:postContentDocumentAsinRelations 为 全量替换 asinSet;从集合中移除 ASIN 会导致该 ASIN 上内容被 suspend(以 Amazon 行为为准)。详见 `references/api.md`。
---
Quick Parameters
通用
| 字段 | 必填 | 说明 |
|---|---|---|
| sellerId | 是 | 已授权 Seller ID(与 storeTokens 一致) |
| region | 是 | NA / EU / FE |
| marketplaceId 或 marketplaceIds | 是 | 目标站点;数组时脚本仅取 第一个 |
contentReferenceKey
多数操作需要 `contentReferenceKey`(来自 searchContentDocuments 等)。官方说明该 key 非永久链接、未来可能变化。
getContentDocument
| 字段 | 必填 | 说明 |
|---|---|---|
| includedDataSet | 是 | 至少一项;`CONTENTS`、`METADATA`(可并存) |
searchContentPublishRecords
| 字段 | 必填 | 说明 |
|---|---|---|
| asin | 是 | 长度 ≥ 10 |
---
Scripts
- `scripts/search_content_documents.py`
- `scripts/create_content_document.py`
- `scripts/get_content_document.py`
- `scripts/update_content_document.py`
- `scripts/list_content_document_asin_relations.py`
- `scripts/post_content_document_asin_relations.py`
- `scripts/validate_content_document_asin_relations.py`
- `scripts/search_content_publish_records.py`
- `scripts/post_content_document_approval_submission.py`
- `scripts/post_content_document_suspend_submission.py`
- `scripts/check_auth_dependency.py`
示例:
export LINKFOXAGENT_API_KEY="<your-key>"
python scripts/search_content_documents.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER"}'
python scripts/get_content_document.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","contentReferenceKey":"YOUR_KEY","includedDataSet":["CONTENTS","METADATA"]}'
python scripts/post_content_document_asin_relations.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","contentReferenceKey":"YOUR_KEY","asinSet":["B0XXXXXXXXXX"]}'
python scripts/search_content_publish_records.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","asin":"B0XXXXXXXXXX"}'
python scripts/post_content_document_approval_submission.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","contentReferenceKey":"YOUR_KEY"}'---
Display Rules
1. 先展示网关 `errcode` / `httpStatus`;成功后再解析 `developerProxy.body` 或各脚本附带的 *`Response** 字段。 2. 说明 **contentReferenceKey` 与前台「A+ ID」不一定一致。 3. 写操作(创建/更新/关联/校验/提交/暂停)前确认用户意图;替换 ASIN 与 暂停展示** 影响线上详情页。
---
Important Limitations
- 应用权限:Seller Central 应用须具备 A+ Content 相关 角色/权限;否则 403。
- 白名单:
aplus/2020-11-01/须在网关 developerProxy 放行;1005 需运维配置。 - `contentDocument` 结构复杂,须符合 Amazon 官方模型;本 skill 只做透传,不内置模板校验。
- marketplaceId:A+ 接口 Query 使用单数 `marketplaceId`(与 Listings 的
marketplaceIds不同)。
Feedback: 见 references/api.md,skillName:linkfox-amazon-store-aplus-content。
--- 更多跨境 skill:[LinkFox Skills](https://skill.linkfox.com/)
<!-- LF_LARGE_RESPONSE_BLOCK -->
Handling Large Responses
To avoid overflowing the agent context, persist the response to disk and extract only the fields you need:
python scripts/response_io.py run --script scripts/check_auth_dependency.py --out-dir <DIR> '<params>'
python scripts/response_io.py read <file> --fields "<paths>" # or --path "<JMESPath>"Pick--out-diroutside any git working tree (e.g./tmp/...on Unix,%TEMP%/...on Windows). Persisted responses may contain PII, pricing, or auth-sensitive data — do not commit them. Files are not auto-deleted; clean up when the task is done.
This skill exposes multiple entry scripts:check_auth_dependency.py,create_content_document.py,get_content_document.py,list_content_document_asin_relations.py,post_content_document_approval_submission.py,post_content_document_asin_relations.py,post_content_document_suspend_submission.py,search_content_documents.py,search_content_publish_records.py,update_content_document.py,validate_content_document_asin_relations.py. Pass--script scripts/<name>.pyto choose the one you need.
run writes the full response to a file and emits only a schema preview + file path. read projects specific fields, with --limit/--offset for slicing and --format json|jsonl|csv|table for output.
When to prefer this pattern — apply your judgment based on the response characteristics, e.g.:
- High field count per record, or fields you don't need
- Batch/paginated results (multiple items per call)
- Long-text fields (descriptions, reviews, HTML, time series)
- Output reused across later steps rather than consumed immediately
For small, single-use responses, calling the main script directly is fine.
⚠️ The preview is a truncated schema + sample, not the full data. Any field-level decision must read from the persisted file via read. <!-- /LF_LARGE_RESPONSE_BLOCK -->
Amazon 店铺 A+ Content Management API 参考(v2020-11-01)
本文档描述通过 LinkFox 店铺网关 调用 Selling Partner API A+ Content Management v2020-11-01:searchContentDocuments、createContentDocument、getContentDocument、updateContentDocument、listContentDocumentAsinRelations、postContentDocumentAsinRelations、validateContentDocumentAsinRelations、searchContentPublishRecords、postContentDocumentApprovalSubmission、postContentDocumentSuspendSubmission。流程与 `linkfox-amazon-store-report` / `linkfox-amazon-store-listings` 一致:先 `POST /spApi/storeTokens`,再 `POST /spApi/developerProxy` 转发上游 GET 或 POST。
官方参考:searchContentDocuments · createContentDocument · getContentDocument · updateContentDocument · listContentDocumentAsinRelations · postContentDocumentAsinRelations · validateContentDocumentAsinRelations · searchContentPublishRecords · postContentDocumentApprovalSubmission · postContentDocumentSuspendSubmission
⚠️ 依赖:需已安装 `linkfox-amazon-store-auth` 并完成店铺授权。
---
调用规范
| 项 | 说明 |
|---|---|
| Base URL | https://tool-gateway.linkfox.com(可用 STORE_API_BASE_URL 或 SPAPI_BASE_URL 覆盖) |
| 网关认证 | Header Authorization: <api_key>,环境变量 LINKFOXAGENT_API_KEY |
| 店铺令牌 | POST /spApi/storeTokens,Body:`{"sellerId":"...","region":"NA\ |
| SP-API 转发 | POST /spApi/developerProxy |
---
POST /spApi/developerProxy
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| region | string | 是 | NA / EU / FE |
| path | string | 是 | 不含主机名;本 API 统一前缀 `aplus/2020-11-01/...`(见各节) |
| method | string | 是 | `GET` 或 `POST`(A+ 的创建/更新/校验/关联/提交均为 POST) |
| amzAccessToken | string | 是 | storeTokens 返回的 accessToken |
| queryString | string | 视操作 | 无 `?` 前缀;Query 键名与官方一致(注意单数 `marketplaceId`) |
| body | string | 视操作 | POST 且带 JSON body 时,为 JSON 字符串;GET 不传 |
| contentType | string | 视操作 | 有 body 时建议 `application/json` |
网关响应(与其它店铺 skill 一致):解析 `errcode`、`httpStatus`,再将 `body` 字符串 JSON.parse。
白名单
path 须在网关 `sp-api.developer-proxy.allowed-path-prefixes` 内。若 `errcode=1005`,需运维放行前缀(常见):`aplus/2020-11-01/`。
速率(文档默认值)
各操作文档默认多为 10 req/s,burst 10;以响应头 x-amzn-RateLimit-Limit 及账号实际配额为准。
---
路径与 Query 总览
| 操作 | Method | developerProxy.path 模板 |
|---|---|---|
| searchContentDocuments | GET | aplus/2020-11-01/contentDocuments |
| createContentDocument | POST | aplus/2020-11-01/contentDocuments |
| getContentDocument | GET | aplus/2020-11-01/contentDocuments/{contentReferenceKey} |
| updateContentDocument | POST | aplus/2020-11-01/contentDocuments/{contentReferenceKey} |
| listContentDocumentAsinRelations | GET | aplus/2020-11-01/contentDocuments/{contentReferenceKey}/asins |
| postContentDocumentAsinRelations | POST | aplus/2020-11-01/contentDocuments/{contentReferenceKey}/asins |
| validateContentDocumentAsinRelations | POST | aplus/2020-11-01/contentAsinValidations |
| searchContentPublishRecords | GET | aplus/2020-11-01/contentPublishRecords |
| postContentDocumentApprovalSubmission | POST | aplus/2020-11-01/contentDocuments/{contentReferenceKey}/approvalSubmissions |
| postContentDocumentSuspendSubmission | POST | aplus/2020-11-01/contentDocuments/{contentReferenceKey}/suspendSubmissions |
Path 编码:contentReferenceKey 等路径段须 百分号编码(脚本已处理)。
---
searchContentDocuments(GET)
| Query | 必填 | 说明 |
|---|---|---|
| marketplaceId | 是 | 目标站点 id |
| pageToken | 否 | 分页 |
返回文档列表(元数据为主);完整内容需 `getContentDocument`。
---
createContentDocument(POST)
| Query | 必填 | 说明 |
|---|---|---|
| marketplaceId | 是 | 目标站点 id |
| Body JSON | 必填 | 说明 |
|---|---|---|
| contentDocument | 是 | A+ 文档对象(结构以 Amazon 模型为准) |
---
getContentDocument(GET)
| Query | 必填 | 说明 |
|---|---|---|
| marketplaceId | 是 | 目标站点 id |
| includedDataSet | 是(≥1) | 可重复键;取值:`CONTENTS`、`METADATA` |
---
updateContentDocument(POST)
| Query | 必填 | 说明 |
|---|---|---|
| marketplaceId | 是 | 目标站点 id |
| Body JSON | 必填 | 说明 |
|---|---|---|
| contentDocument | 是 | 更新后的 A+ 文档 |
---
listContentDocumentAsinRelations(GET)
| Query | 必填 | 说明 |
|---|---|---|
| marketplaceId | 是 | 目标站点 id |
| includedDataSet | 否 | 可选 `METADATA`;不传则通常仅返回关联 ASIN |
| asinSet | 否 | 可重复;筛选指定 ASIN |
| pageToken | 否 | 分页 |
---
postContentDocumentAsinRelations(POST)
| Query | 必填 | 说明 |
|---|---|---|
| marketplaceId | 是 | 目标站点 id |
| Body JSON | 必填 | 说明 |
|---|---|---|
| asinSet | 是 | 替换该文档关联的全部 ASIN(官方语义为全量替换;移除 ASIN 会 suspend 该 ASIN 上的内容)。可为 字符串数组,或与官方 schema 一致的对象数组 |
---
validateContentDocumentAsinRelations(POST)
| Query | 必填 | 说明 |
|---|---|---|
| marketplaceId | 是 | 目标站点 id |
| asinSet | 否 | 可重复;待校验 ASIN 集合 |
| Body JSON | 必填 | 说明 |
|---|---|---|
| contentDocument | 是 | 待校验的 A+ 文档 |
---
searchContentPublishRecords(GET)
| Query | 必填 | 说明 |
|---|---|---|
| marketplaceId | 是 | 目标站点 id |
| asin | 是 | ASIN,文档要求 length ≥ 10 |
| pageToken | 否 | 分页 |
---
postContentDocumentApprovalSubmission(POST)
| Query | 必填 | 说明 |
|---|---|---|
| marketplaceId | 是 | 目标站点 id |
Body:官方无必填 body;本仓库脚本 不传 developerProxy.body。
---
postContentDocumentSuspendSubmission(POST)
| Query | 必填 | 说明 |
|---|---|---|
| marketplaceId | 是 | 目标站点 id |
请求暂停详情页可见 A+;不删除文档与 ASIN 关联。脚本 不传 body。
---
脚本入参约定(JSON 一行)
各脚本均需 `sellerId`、`region`;站点 id 使用 `marketplaceId` 或 `marketplaceIds`(数组时仅取第一个,与其它店铺 skill 一致)。
| 脚本 | 额外必填 | 可选 |
|---|---|---|
search_content_documents.py | — | pageToken |
create_content_document.py | contentDocument | — |
get_content_document.py | contentReferenceKey, includedDataSet | — |
update_content_document.py | contentReferenceKey, contentDocument | — |
list_content_document_asin_relations.py | contentReferenceKey | includedDataSet, asinSet, pageToken |
post_content_document_asin_relations.py | contentReferenceKey, asinSet | — |
validate_content_document_asin_relations.py | contentDocument | asinSet(写入 Query) |
search_content_publish_records.py | asin | pageToken |
post_content_document_approval_submission.py | contentReferenceKey | — |
post_content_document_suspend_submission.py | contentReferenceKey | — |
全局可选:`skipDepCheck`: true(跳过本地依赖探测,不建议常规使用)。
---
curl 示例(网关层示意)
export LINKFOXAGENT_API_KEY="<your-key>"
# 1) 取令牌(示意)
curl -sS -X POST "https://tool-gateway.linkfox.com/spApi/storeTokens" \
-H "Authorization: $LINKFOXAGENT_API_KEY" -H "Content-Type: application/json" \
-d '{"sellerId":"A1...","region":"NA"}'
# 2) 转发 searchContentDocuments(将 ACCESS_TOKEN 替换为上一步 accessToken)
curl -sS -X POST "https://tool-gateway.linkfox.com/spApi/developerProxy" \
-H "Authorization: $LINKFOXAGENT_API_KEY" -H "Content-Type: application/json" \
-d '{
"region":"NA",
"path":"aplus/2020-11-01/contentDocuments",
"method":"GET",
"amzAccessToken":"ACCESS_TOKEN",
"queryString":"marketplaceId=ATVPDKIKX0DER"
}'---
Feedback
与其它 LinkFox 店铺 skill 相同:若仓库或产品内提供 Feedback API,上报时 `skillName` 请使用 `linkfox-amazon-store-aplus-content`。
"""Shared helpers for linkfox-amazon-store-aplus-content (A+ Content Management v2020-11-01)."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any, Optional
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
REQUIRED_SKILL = "linkfox-amazon-store-auth"
DEPENDENCY_EXIT_CODE = 42
APLUS_API_VERSION = "2020-11-01"
APLUS_PATH_PREFIX = f"aplus/{APLUS_API_VERSION}"
API_BASE_URL = os.environ.get("STORE_API_BASE_URL") or os.environ.get(
"SPAPI_BASE_URL", "https://tool-gateway.linkfox.com"
)
STORE_TOKENS_ENDPOINT = f"{API_BASE_URL.rstrip('/')}/spApi/storeTokens"
DEVELOPER_PROXY_ENDPOINT = f"{API_BASE_URL.rstrip('/')}/spApi/developerProxy"
def ensure_auth_skill_available(caller_script: str) -> None:
here = Path(__file__).resolve().parent
checker = here / "check_auth_dependency.py"
if not checker.exists():
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": f"check_auth_dependency.py not found next to {caller_script}",
}
print(f"DEPENDENCY_MISSING: {json.dumps(payload, ensure_ascii=False)}", file=sys.stderr)
sys.exit(DEPENDENCY_EXIT_CODE)
try:
result = subprocess.run(
[sys.executable, str(checker)],
capture_output=True,
text=True,
timeout=10,
)
except Exception as exc: # pragma: no cover
payload = {"missingSkill": REQUIRED_SKILL, "reason": str(exc)}
print(f"DEPENDENCY_MISSING: {json.dumps(payload, ensure_ascii=False)}", file=sys.stderr)
sys.exit(DEPENDENCY_EXIT_CODE)
if result.stderr:
sys.stderr.write(result.stderr)
if not result.stderr.endswith("\n"):
sys.stderr.write("\n")
if result.returncode != 0:
sys.exit(DEPENDENCY_EXIT_CODE)
def get_api_key() -> str:
key = os.environ.get("LINKFOXAGENT_API_KEY")
if not key:
print(
"API Key not configured. Set:\n export LINKFOXAGENT_API_KEY=<your-key>",
file=sys.stderr,
)
sys.exit(1)
return key
def call_api(endpoint: str, params: dict, timeout: int = 120) -> dict:
api_key = get_api_key()
data = json.dumps(params).encode("utf-8")
req = Request(
endpoint,
data=data,
headers={
"Authorization": api_key,
"Content-Type": "application/json",
"User-Agent": "LinkFox-Skill/1.0",
},
method="POST",
)
try:
with urlopen(req, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as e:
body = e.read().decode("utf-8") if e.fp else ""
return {"error": f"HTTP {e.code}: {e.reason}", "details": body}
except URLError as e:
return {"error": f"Connection failed: {e.reason}"}
def get_store_tokens(seller_id: str, region: str) -> dict:
return call_api(STORE_TOKENS_ENDPOINT, {"sellerId": seller_id, "region": region})
def developer_proxy_get(
region: str,
path: str,
access_token: str,
query_string: Optional[str] = None,
timeout: int = 120,
) -> dict:
params: dict[str, Any] = {
"region": region,
"path": path,
"method": "GET",
"amzAccessToken": access_token,
}
if query_string:
params["queryString"] = query_string
return call_api(DEVELOPER_PROXY_ENDPOINT, params, timeout=timeout)
def developer_proxy_post(
region: str,
path: str,
access_token: str,
*,
query_string: Optional[str] = None,
body_obj: Optional[dict[str, Any]] = None,
timeout: int = 120,
) -> dict:
params: dict[str, Any] = {
"region": region,
"path": path,
"method": "POST",
"amzAccessToken": access_token,
"contentType": "application/json",
}
if query_string:
params["queryString"] = query_string
if body_obj is not None:
params["body"] = json.dumps(body_obj, ensure_ascii=False)
return call_api(DEVELOPER_PROXY_ENDPOINT, params, timeout=timeout)
def resolve_marketplace_id(params: dict, api_name: str) -> str:
mid = params.get("marketplaceId")
if mid is None and params.get("marketplaceIds") is not None:
mids = params["marketplaceIds"]
if isinstance(mids, list) and mids:
mid = mids[0]
if len(mids) > 1:
print(
f"⚠️ Warning: {api_name} uses a single marketplaceId; using first marketplaceIds only.",
file=sys.stderr,
)
elif isinstance(mids, str) and mids.strip():
mid = mids.strip()
if mid is None or (isinstance(mid, str) and not mid.strip()):
raise ValueError("Missing marketplaceId (or non-empty marketplaceIds)")
return str(mid).strip()
def encode_path_segment(value: str) -> str:
return quote(value, safe="")
def merge_success_json(out: dict, proxy: dict, result_key: str) -> None:
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
body_raw = proxy.get("body") or "{}"
try:
out[result_key] = json.loads(body_raw)
except json.JSONDecodeError:
out[result_key] = None
out[f"{result_key}Raw"] = body_raw
#!/usr/bin/env python3
"""
Dependency Check - linkfox-amazon-store-aplus-content
======================================================
本脚本用于判断当前运行环境里是否已经安装 / 加载了依赖 skill
`linkfox-amazon-store-auth`(与 `linkfox-amazon-store-report` 等共用同一检查逻辑)。
用法:
python check_auth_dependency.py # 默认检查
python check_auth_dependency.py --json # 以 JSON 输出结果
退出码约定(供 agent 程序化解析):
0 → 依赖已满足(找到 linkfox-amazon-store-auth 的 SKILL.md)
42 → DEPENDENCY_MISSING: 未找到依赖 skill,agent 需要触发安装流程
stderr 结构化信号:
- 若依赖缺失,stderr 第一行会以 `DEPENDENCY_MISSING:` 开头,
后跟 JSON payload,包含所需 skill 名与建议的安装动作。
- 成功时 stderr 以 `DEPENDENCY_OK:` 开头。
注意:
这是一个**不联网**的本地探测脚本。它只检查文件系统上常见的
skill 安装路径(含 **OpenClaw**、**Hermes Agent** 的常见布局);
真正的"能不能调授权接口"取决于依赖 skill 的脚本是否可执行——
这一点由各业务脚本在运行时通过调用 `/spApi/storeTokens` 再做校验。
OpenClaw 参考: workspace 下 `<workspace>/skills`、`<workspace>/.agents/skills`,
以及 `~/.openclaw/skills`、`~/.agents/skills`(与官方文档优先级一致)。
Hermes Agent 参考: `~/.hermes/skills/<category>/<skill-name>/SKILL.md`,
以及 `~/.hermes/plugins/<plugin>/skills/<skill-name>/SKILL.md`;
额外目录可在 `~/.hermes/config.yaml` 的 `skills.external_dirs` 中配置,
本脚本无法解析 YAML,请通过环境变量 `HERMES_SKILLS_EXTERNAL_DIRS`(冒号
或分号分隔的多个路径)或通用的 `LINKFOX_SKILLS_DIR` / `SKILLS_DIR` 注入。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
REQUIRED_SKILL = "linkfox-amazon-store-auth"
# 仍兼容本机未重命名时的旧目录名(历史安装 linkfox-amazon-spapi-auth)
_AUTH_SKILL_DIR_ALIASES = ("linkfox-amazon-store-auth", "linkfox-amazon-spapi-auth")
DEPENDENCY_EXIT_CODE = 42
def _split_path_list(raw: str | None) -> list[Path]:
"""按 OS 路径分隔符拆分(Windows 为 `;`,Unix 为 `:`),避免误拆盘符。"""
if not raw or not raw.strip():
return []
parts = [p.strip() for p in raw.split(os.pathsep) if p.strip()]
return [Path(p).expanduser() for p in parts]
def candidate_skill_roots() -> list[Path]:
"""按常见的 skill 存放位置,由近到远返回候选根目录(扁平:root/<skill>/SKILL.md)。"""
roots: list[Path] = []
for env_var in ("LINKFOX_SKILLS_DIR", "SKILLS_DIR", "CURSOR_SKILLS_DIR"):
p = os.environ.get(env_var)
if p:
roots.append(Path(p).expanduser())
roots.extend(_split_path_list(os.environ.get("HERMES_SKILLS_EXTERNAL_DIRS")))
for env_var in ("OPENCLAW_WORKSPACE", "OPENCLAW_ROOT", "OPENCLAW_WORKDIR"):
ws = os.environ.get(env_var)
if ws:
w = Path(ws).expanduser()
roots.append(w / "skills")
roots.append(w / ".agents" / "skills")
oc_skills = os.environ.get("OPENCLAW_SKILLS_DIR")
if oc_skills:
roots.append(Path(oc_skills).expanduser())
try:
cwd = Path.cwd()
roots.append(cwd / "skills")
roots.append(cwd / ".agents" / "skills")
except OSError:
pass
here = Path(__file__).resolve()
if len(here.parents) >= 3:
roots.append(here.parents[2])
home = Path.home()
roots.extend([
home / ".claude" / "skills",
home / ".cursor" / "skills",
home / ".cursor" / "skills-cursor",
home / ".linkfox" / "skills",
])
roots.extend([
home / ".openclaw" / "skills",
home / ".hermes" / "skills",
])
seen: set[Path] = set()
unique: list[Path] = []
for r in roots:
try:
rr = r.resolve()
except OSError:
rr = r
if rr not in seen:
seen.add(rr)
unique.append(r)
return unique
def _hermes_category_skill_md(hermes_skills_root: Path, skill_dir_name: str) -> Path | None:
if not hermes_skills_root.is_dir():
return None
for category_dir in sorted(hermes_skills_root.iterdir()):
if not category_dir.is_dir():
continue
name = category_dir.name
if name.startswith(".") or name == ".hub":
continue
candidate = category_dir / skill_dir_name / "SKILL.md"
if candidate.is_file():
return candidate
return None
def _hermes_plugin_skill_md(home: Path, skill_dir_name: str) -> Path | None:
plugins_root = home / ".hermes" / "plugins"
if not plugins_root.is_dir():
return None
for plugin_dir in sorted(plugins_root.iterdir()):
if not plugin_dir.is_dir():
continue
candidate = plugin_dir / "skills" / skill_dir_name / "SKILL.md"
if candidate.is_file():
return candidate
return None
def locate_dependency() -> Path | None:
home = Path.home()
for skill_dir_name in _AUTH_SKILL_DIR_ALIASES:
for root in candidate_skill_roots():
target = root / skill_dir_name / "SKILL.md"
if target.is_file():
return target
hermes_default = home / ".hermes" / "skills"
found = _hermes_category_skill_md(hermes_default, skill_dir_name)
if found is not None:
return found
hsh = os.environ.get("HERMES_SKILLS_HOME")
if hsh:
found = _hermes_category_skill_md(Path(hsh).expanduser(), skill_dir_name)
if found is not None:
return found
found = _hermes_plugin_skill_md(home, skill_dir_name)
if found is not None:
return found
return None
def searched_locations_for_report() -> list[str]:
home = Path.home()
out: list[str] = [str(p) for p in candidate_skill_roots()]
out.append(str(home / ".hermes" / "skills"))
out.append(str(home / ".hermes" / "plugins"))
hsh = os.environ.get("HERMES_SKILLS_HOME")
if hsh:
out.append(str(Path(hsh).expanduser()))
seen: set[str] = set()
unique: list[str] = []
for s in out:
if s not in seen:
seen.add(s)
unique.append(s)
return unique
def emit(as_json: bool, ok: bool, payload: dict) -> None:
prefix = "DEPENDENCY_OK:" if ok else "DEPENDENCY_MISSING:"
body = json.dumps(payload, ensure_ascii=False)
if as_json:
out = dict(payload)
out["status"] = "ok" if ok else "missing"
print(json.dumps(out, ensure_ascii=False, indent=2))
print(f"{prefix} {body}", file=sys.stderr)
def main() -> None:
parser = argparse.ArgumentParser(description="Check required dependency skill availability.")
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON on stdout.")
args = parser.parse_args()
found = locate_dependency()
if found is not None:
emit(
as_json=args.json,
ok=True,
payload={
"skill": REQUIRED_SKILL,
"skillMdPath": str(found),
},
)
sys.exit(0)
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": (
f"linkfox-amazon-store-aplus-content 依赖 `{REQUIRED_SKILL}`,"
"但在常见 skill 安装路径下未找到其 SKILL.md。"
),
"searchedRoots": searched_locations_for_report(),
"suggestedActions": [
f"If a skill installer tool is available (e.g. install_skill / skill marketplace MCP), invoke it to install '{REQUIRED_SKILL}' immediately.",
"Otherwise, ask the user to install the skill from https://skill.linkfox.com/ and retry.",
"On OpenClaw: ensure the dependency is under <workspace>/skills, ~/.openclaw/skills, or ~/.agents/skills; set OPENCLAW_WORKSPACE or OPENCLAW_SKILLS_DIR if installs are non-default.",
"On Hermes Agent: ensure the dependency is under ~/.hermes/skills/<category>/ or a plugin skills/ folder; for external_dirs from config.yaml, export HERMES_SKILLS_EXTERNAL_DIRS with OS path separators.",
"Do NOT bypass the dependency by calling /spApi/authorizeUrl or /spApi/storeTokens directly from this skill.",
],
"marketplaceUrl": "https://skill.linkfox.com/",
}
emit(as_json=args.json, ok=False, payload=payload)
sys.exit(DEPENDENCY_EXIT_CODE)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — createContentDocument (SP-API A+ Content Management v2020-11-01)
官方参考: https://developer-docs.amazon.com/sp-api/reference/createcontentdocument
Usage:
python create_content_document.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","contentDocument":{...}}'
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_aplus_common import (
APLUS_PATH_PREFIX,
ensure_auth_skill_available,
get_store_tokens,
developer_proxy_post,
resolve_marketplace_id,
merge_success_json,
)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: create_content_document.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds), contentDocument (object)\n"
"Optional: skipDepCheck",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("create_content_document.py")
for f in ("sellerId", "region", "contentDocument"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
try:
mid = resolve_marketplace_id(params, "createContentDocument")
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
cd = params["contentDocument"]
if not isinstance(cd, dict):
print("contentDocument must be a JSON object", file=sys.stderr)
sys.exit(1)
query_string = f"marketplaceId={quote(mid, safe='')}"
path = f"{APLUS_PATH_PREFIX}/contentDocuments"
body = {"contentDocument": cd}
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_post(
region, path, tokens["accessToken"], query_string=query_string, body_obj=body
)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
merge_success_json(out, proxy, "createContentDocumentResponse")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — getContentDocument (SP-API A+ Content Management v2020-11-01)
官方参考: https://developer-docs.amazon.com/sp-api/reference/getcontentdocument
includedDataSet 为必填(至少一项),常见取值: CONTENTS, METADATA
Usage:
python get_content_document.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","contentReferenceKey":"...","includedDataSet":["CONTENTS","METADATA"]}'
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_aplus_common import (
APLUS_PATH_PREFIX,
ensure_auth_skill_available,
get_store_tokens,
developer_proxy_get,
resolve_marketplace_id,
encode_path_segment,
merge_success_json,
)
def _norm_str_list(val: object, field: str) -> list[str]:
if isinstance(val, str):
return [x.strip() for x in val.split(",") if x.strip()]
if isinstance(val, list):
return [str(x).strip() for x in val if str(x).strip()]
raise ValueError(f"{field} must be a non-empty string, comma string, or array")
def _build_query(marketplace_id: str, included_data_set: list[str]) -> str:
parts = [f"marketplaceId={quote(marketplace_id, safe='')}"]
for item in included_data_set:
parts.append(f"includedDataSet={quote(item, safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: get_content_document.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds), contentReferenceKey, includedDataSet (array, len>=1)\n"
"Optional: skipDepCheck",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("get_content_document.py")
for f in ("sellerId", "region", "contentReferenceKey", "includedDataSet"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
try:
mid = resolve_marketplace_id(params, "getContentDocument")
ids = _norm_str_list(params["includedDataSet"], "includedDataSet")
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
if not ids:
print("includedDataSet must contain at least one value", file=sys.stderr)
sys.exit(1)
key = str(params["contentReferenceKey"]).strip()
if not key:
print("contentReferenceKey must be non-empty", file=sys.stderr)
sys.exit(1)
query_string = _build_query(mid, ids)
path = f"{APLUS_PATH_PREFIX}/contentDocuments/{encode_path_segment(key)}"
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_get(region, path, tokens["accessToken"], query_string)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
merge_success_json(out, proxy, "getContentDocumentResponse")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — listContentDocumentAsinRelations (SP-API A+ Content Management v2020-11-01)
官方参考: https://developer-docs.amazon.com/sp-api/reference/listcontentdocumentasinrelations
Usage:
python list_content_document_asin_relations.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","contentReferenceKey":"..."}'
# 可选: includedDataSet, asinSet, pageToken
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_aplus_common import (
APLUS_PATH_PREFIX,
ensure_auth_skill_available,
get_store_tokens,
developer_proxy_get,
resolve_marketplace_id,
encode_path_segment,
merge_success_json,
)
def _append_asin_set(parts: list[str], asin_set: object | None) -> None:
if asin_set is None:
return
if isinstance(asin_set, str):
vals = [x.strip() for x in asin_set.split(",") if x.strip()]
elif isinstance(asin_set, list):
vals = [str(x).strip() for x in asin_set if str(x).strip()]
else:
raise ValueError("asinSet must be string, comma string, or array")
for a in vals:
parts.append(f"asinSet={quote(a, safe='')}")
def _append_included(parts: list[str], included: object | None) -> None:
if included is None:
return
if isinstance(included, str):
vals = [x.strip() for x in included.split(",") if x.strip()]
elif isinstance(included, list):
vals = [str(x).strip() for x in included if str(x).strip()]
else:
raise ValueError("includedDataSet must be string, comma string, or array")
for v in vals:
parts.append(f"includedDataSet={quote(v, safe='')}")
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: list_content_document_asin_relations.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds), contentReferenceKey\n"
"Optional: includedDataSet, asinSet, pageToken, skipDepCheck",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("list_content_document_asin_relations.py")
for f in ("sellerId", "region", "contentReferenceKey"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
try:
mid = resolve_marketplace_id(params, "listContentDocumentAsinRelations")
parts = [f"marketplaceId={quote(mid, safe='')}"]
_append_included(parts, params.get("includedDataSet"))
_append_asin_set(parts, params.get("asinSet"))
pt = params.get("pageToken")
if pt:
parts.append(f"pageToken={quote(str(pt).strip(), safe='')}")
query_string = "&".join(parts)
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
key = str(params["contentReferenceKey"]).strip()
if not key:
print("contentReferenceKey must be non-empty", file=sys.stderr)
sys.exit(1)
path = f"{APLUS_PATH_PREFIX}/contentDocuments/{encode_path_segment(key)}/asins"
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_get(region, path, tokens["accessToken"], query_string)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
merge_success_json(out, proxy, "listContentDocumentAsinRelationsResponse")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — postContentDocumentApprovalSubmission (SP-API A+ Content Management v2020-11-01)
提交 A+ 文档供审核、批准与发布。
官方参考: https://developer-docs.amazon.com/sp-api/reference/postcontentdocumentapprovalsubmission
Usage:
python post_content_document_approval_submission.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","contentReferenceKey":"..."}'
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_aplus_common import (
APLUS_PATH_PREFIX,
ensure_auth_skill_available,
get_store_tokens,
developer_proxy_post,
resolve_marketplace_id,
encode_path_segment,
merge_success_json,
)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: post_content_document_approval_submission.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds), contentReferenceKey\n"
"Optional: skipDepCheck",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("post_content_document_approval_submission.py")
for f in ("sellerId", "region", "contentReferenceKey"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
try:
mid = resolve_marketplace_id(params, "postContentDocumentApprovalSubmission")
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
key = str(params["contentReferenceKey"]).strip()
if not key:
print("contentReferenceKey must be non-empty", file=sys.stderr)
sys.exit(1)
query_string = f"marketplaceId={quote(mid, safe='')}"
path = (
f"{APLUS_PATH_PREFIX}/contentDocuments/{encode_path_segment(key)}/approvalSubmissions"
)
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_post(
region, path, tokens["accessToken"], query_string=query_string, body_obj=None
)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
merge_success_json(out, proxy, "postContentDocumentApprovalSubmissionResponse")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — postContentDocumentAsinRelations (SP-API A+ Content Management v2020-11-01)
用请求体中的 asinSet **整体替换**该 A+ 文档关联的全部 ASIN(可增可减;移除 ASIN 会 suspend 该 ASIN 上的展示)。
官方参考: https://developer-docs.amazon.com/sp-api/reference/postcontentdocumentasinrelations
Usage:
python post_content_document_asin_relations.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","contentReferenceKey":"...","asinSet":["B0...","B0..."]}'
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_aplus_common import (
APLUS_PATH_PREFIX,
ensure_auth_skill_available,
get_store_tokens,
developer_proxy_post,
resolve_marketplace_id,
encode_path_segment,
merge_success_json,
)
def _norm_asin_set(val: object) -> object:
if isinstance(val, str):
return [x.strip() for x in val.split(",") if x.strip()]
if isinstance(val, list):
if not val:
return []
if all(isinstance(x, dict) for x in val):
return val
return [str(x).strip() for x in val if str(x).strip()]
raise ValueError(
"asinSet must be a string, comma-separated string, array of ASIN strings, or array of objects (Amazon schema)"
)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: post_content_document_asin_relations.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds), contentReferenceKey, asinSet (array or comma string)\n"
"Optional: skipDepCheck",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("post_content_document_asin_relations.py")
for f in ("sellerId", "region", "contentReferenceKey", "asinSet"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
try:
mid = resolve_marketplace_id(params, "postContentDocumentAsinRelations")
asin_payload = _norm_asin_set(params["asinSet"])
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
key = str(params["contentReferenceKey"]).strip()
if not key:
print("contentReferenceKey must be non-empty", file=sys.stderr)
sys.exit(1)
query_string = f"marketplaceId={quote(mid, safe='')}"
path = f"{APLUS_PATH_PREFIX}/contentDocuments/{encode_path_segment(key)}/asins"
body = {"asinSet": asin_payload}
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_post(
region, path, tokens["accessToken"], query_string=query_string, body_obj=body
)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
merge_success_json(out, proxy, "postContentDocumentAsinRelationsResponse")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — postContentDocumentSuspendSubmission (SP-API A+ Content Management v2020-11-01)
请求暂停 A+ 在详情页的可见展示;**不会**删除文档本体或 ASIN 关联。
官方参考: https://developer-docs.amazon.com/sp-api/reference/postcontentdocumentsuspendsubmission
Usage:
python post_content_document_suspend_submission.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","contentReferenceKey":"..."}'
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_aplus_common import (
APLUS_PATH_PREFIX,
ensure_auth_skill_available,
get_store_tokens,
developer_proxy_post,
resolve_marketplace_id,
encode_path_segment,
merge_success_json,
)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: post_content_document_suspend_submission.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds), contentReferenceKey\n"
"Optional: skipDepCheck",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("post_content_document_suspend_submission.py")
for f in ("sellerId", "region", "contentReferenceKey"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
try:
mid = resolve_marketplace_id(params, "postContentDocumentSuspendSubmission")
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
key = str(params["contentReferenceKey"]).strip()
if not key:
print("contentReferenceKey must be non-empty", file=sys.stderr)
sys.exit(1)
query_string = f"marketplaceId={quote(mid, safe='')}"
path = (
f"{APLUS_PATH_PREFIX}/contentDocuments/{encode_path_segment(key)}/suspendSubmissions"
)
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_post(
region, path, tokens["accessToken"], query_string=query_string, body_obj=None
)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
merge_success_json(out, proxy, "postContentDocumentSuspendSubmissionResponse")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Skill response I/O helper — wraps any main script to persist large API
responses to disk, then offers a `read` subcommand to extract specific fields
from those persisted files. Generic, business-agnostic.
This script is bundled into each skill's scripts/ directory by tools/response_io/sync.py.
The agent must pass --script <path> to identify which main script to execute.
Usage:
python scripts/response_io.py run --script <PATH> --out-dir <DIR> '<json_params>' [--label NAME] [--timeout SEC]
python scripts/response_io.py read <file> (--path "<JMESPath>" | --fields "f1,f2,...") [--limit N] [--offset M] [--format json|jsonl|csv|table]
"""
from __future__ import annotations
import sys
if sys.version_info < (3, 10):
sys.exit(
"Error: Python 3.10+ required (current: "
f"{sys.version_info.major}.{sys.version_info.minor}). "
"Please upgrade Python."
)
import argparse
import csv
import io
import json
import os
import re
import secrets
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
# Force UTF-8 stdout/stderr so non-ASCII chars in previews and API responses
# print correctly on Windows (default cp936 / gbk).
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
except (AttributeError, OSError):
pass
try:
import jmespath # type: ignore
HAS_JMESPATH = True
except ImportError:
HAS_JMESPATH = False
MAX_STRING_LEN = 120
MAX_DEPTH = 3
SAMPLE_KEY_CAP = 15
RAW_TEXT_PEEK = 500
DEFAULT_TIMEOUT_SEC = 300
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _err(msg: str, code: int = 1) -> None:
print(msg, file=sys.stderr)
sys.exit(code)
def _resolve_script(script_arg: str) -> Path:
p = Path(script_arg).expanduser()
if not p.is_absolute():
# Resolve relative to the current working directory the agent invoked from.
p = (Path.cwd() / p).resolve()
else:
p = p.resolve()
if not p.is_file():
_err(f"--script path not found: {p}")
return p
def _resolve_skill_name(main_script: Path) -> str:
"""Best-effort skill name extraction for filename prefixing.
main_script lives at <skill_dir>/scripts/<name>.py — return <skill_dir>'s
folder name. Fall back to the script's stem if structure differs.
"""
try:
if main_script.parent.name == "scripts":
return main_script.parents[1].name
except IndexError:
pass
return main_script.stem
def _sanitize_label(label: str) -> str:
"""Allow only safe filename chars in --label to prevent path traversal."""
cleaned = re.sub(r"[^\w\-]", "_", label)
return cleaned[:64] # cap length
def _truncate_string(s: str) -> str:
if len(s) <= MAX_STRING_LEN:
return s
return s[:MAX_STRING_LEN] + f"...(truncated, total {len(s)} chars)"
def _truncate_value(value: Any, depth: int = 0) -> Any:
"""Recursively truncate strings, deep nesting, and large arrays for preview."""
if depth >= MAX_DEPTH:
if isinstance(value, dict):
return f"<truncated nested object, keys: {list(value.keys())[:10]}>"
if isinstance(value, list):
return f"<truncated nested array, length: {len(value)}>"
if isinstance(value, str):
return _truncate_string(value)
return value
if isinstance(value, str):
return _truncate_string(value)
if isinstance(value, dict):
out = {k: _truncate_value(v, depth + 1) for k, v in value.items()}
return out
if isinstance(value, list):
if not value:
return []
truncated = [_truncate_value(value[0], depth + 1)]
if len(value) > 1:
# Note total length on the parent — keep the array type-homogeneous
# so downstream consumers can iterate without special-casing strings.
truncated.append({"_omitted_items": len(value) - 1})
return truncated
return value
def _shape_of(value: Any, top: bool = False) -> Any:
"""Lightweight schema description for the preview block."""
if isinstance(value, dict):
keys = list(value.keys())
out: dict[str, Any] = {"type": "object", "top_keys" if top else "keys": keys}
if top:
for k in keys[:8]:
out[k] = _shape_of(value[k])
return out
if isinstance(value, list):
out = {"type": "array", "length": len(value)}
if value and isinstance(value[0], dict):
out["item_keys"] = list(value[0].keys())
elif value:
out["item_type"] = type(value[0]).__name__
return out
return {"type": type(value).__name__}
def _build_sample(value: Any) -> Any:
"""First-record sample with explicit truncation marker."""
if isinstance(value, list):
if not value:
return {"_truncated_record": True, "_note": "array is empty"}
first = value[0]
if isinstance(first, dict):
sample = {"_truncated_record": True, "_note": f"first of {len(value)} items"}
sample.update(_truncate_value(first, depth=1))
return sample
return {"_truncated_record": True, "_note": f"first of {len(value)} items", "value": _truncate_value(first, depth=1)}
if isinstance(value, dict):
sample = {"_truncated_record": True, "_note": "top-level object (truncated)"}
sample.update(_truncate_value(value, depth=1))
return sample
return {"_truncated_record": True, "value": _truncate_value(value, depth=1)}
def _shrink_preview(preview: dict) -> dict:
"""Cap the sample's value fields when it has many keys.
`shape.*.item_keys` is the single source of truth for the full key list
(always complete, no truncation). The sample only ever shows up to
SAMPLE_KEY_CAP fields with their concrete values, since the agent only
needs a feel for value shapes — for the full menu of available fields,
they read `shape`.
"""
sample = preview.get("sample")
if isinstance(sample, dict):
meta_keys = {"_truncated_record", "_note"}
data_keys = [k for k in sample.keys() if k not in meta_keys]
if len(data_keys) > SAMPLE_KEY_CAP:
kept = data_keys[:SAMPLE_KEY_CAP]
new_sample = {k: v for k, v in sample.items() if k in meta_keys or k in kept}
base_note = sample.get("_note", "")
extra = (
f"showing first {SAMPLE_KEY_CAP} of {len(data_keys)} fields "
f"(see `shape` for the complete key list)"
)
new_sample["_note"] = f"{base_note}; {extra}" if base_note else extra
preview["sample"] = new_sample
return preview
# ---------------------------------------------------------------------------
# `run` subcommand
# ---------------------------------------------------------------------------
def cmd_run(args: argparse.Namespace) -> int:
main_script = _resolve_script(args.script)
skill_name = _resolve_skill_name(main_script)
out_dir = Path(args.out_dir).expanduser().resolve()
try:
out_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
_err(f"Failed to create --out-dir {out_dir}: {e}")
if not os.access(out_dir, os.W_OK):
_err(f"--out-dir is not writable: {out_dir}")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
rand = secrets.token_hex(3)
safe_label = _sanitize_label(args.label) if args.label else ""
label_part = f"__{safe_label}" if safe_label else ""
out_file = out_dir / f"{skill_name}__{timestamp}_{rand}{label_part}.json"
# Force the child process to emit UTF-8 regardless of the host console
# encoding (Windows defaults to cp936 / gbk and would otherwise corrupt
# non-ASCII bytes when we read them back).
child_env = os.environ.copy()
child_env["PYTHONIOENCODING"] = "utf-8"
timed_out = False
try:
proc = subprocess.run(
[sys.executable, str(main_script), args.params],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=child_env,
timeout=args.timeout,
)
stdout_text = proc.stdout or ""
stderr_text = proc.stderr or ""
returncode = proc.returncode
except subprocess.TimeoutExpired as e:
timed_out = True
stdout_text = (e.stdout.decode("utf-8", errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or "")) or ""
stderr_text = (e.stderr.decode("utf-8", errors="replace") if isinstance(e.stderr, bytes) else (e.stderr or "")) or ""
returncode = 124 # convention for timeout
# Always write the captured stdout to disk, even if not JSON.
try:
out_file.write_text(stdout_text, encoding="utf-8")
except OSError as e:
_err(f"Failed to write output file {out_file}: {e}")
if stderr_text:
sys.stderr.write(stderr_text)
# Try to parse the captured stdout as JSON for the preview.
try:
parsed = json.loads(stdout_text) if stdout_text.strip() else None
format_kind = "json"
except json.JSONDecodeError:
parsed = None
format_kind = "raw_text"
preview: dict[str, Any] = {
"_preview": {
"is_preview": True,
"warning": (
"PREVIEW ONLY — NOT FULL DATA. The full response is saved to `file`. "
"Use `python scripts/response_io.py read <file> --fields '...'` to extract "
"specific fields, or `--path '<JMESPath>'` for complex projections."
),
},
}
# Surface failures prominently so agents don't mistake a stub preview for success.
if returncode != 0 or timed_out:
stderr_snippet = stderr_text[-500:] if stderr_text else ""
preview["_error"] = {
"exit_code": returncode,
"timed_out": timed_out,
"stderr_snippet": stderr_snippet,
"hint": "The wrapped script failed or timed out. The output file may be empty or partial.",
}
preview.update({
"file": str(out_file),
"size_bytes": out_file.stat().st_size,
"skill": skill_name,
"exit_code": returncode,
"format": format_kind,
"label": safe_label or None,
"next_steps_hint": (
"use: python scripts/response_io.py read <file> --fields '...' | --path '...'"
),
})
if format_kind == "json":
preview["shape"] = _shape_of(parsed, top=True)
preview["sample"] = _build_sample(parsed)
else:
peek = stdout_text[:RAW_TEXT_PEEK]
preview["raw_text_peek"] = peek
preview["raw_text_total_chars"] = len(stdout_text)
preview["sample"] = {
"_truncated_record": True,
"_note": f"stdout was not valid JSON; first {RAW_TEXT_PEEK} chars shown above in raw_text_peek",
}
preview = _shrink_preview(preview)
print(json.dumps(preview, ensure_ascii=False, indent=2))
return returncode
# ---------------------------------------------------------------------------
# `read` subcommand
# ---------------------------------------------------------------------------
def _load_json(path: Path) -> Any:
try:
text = path.read_text(encoding="utf-8")
except OSError as e:
_err(f"Failed to read file {path}: {e}")
try:
return json.loads(text)
except json.JSONDecodeError as e:
_err(f"File is not valid JSON: {path}\n{e}")
def _basic_dot_path(data: Any, path: str) -> Any:
"""Pure-stdlib dot-path resolver. No [*] support — callers fall back here only when jmespath is unavailable AND the path has no [*]."""
cur = data
for part in path.split("."):
if isinstance(cur, dict):
cur = cur.get(part)
else:
return None
return cur
def _resolve_field(data: Any, expr: str) -> Any:
if HAS_JMESPATH:
return jmespath.search(expr, data)
if "[" in expr or "*" in expr:
_err(
f"jmespath is required for expression '{expr}'. "
f"Install with: pip install jmespath"
)
return _basic_dot_path(data, expr)
def _project_fields(data: Any, fields: list[str]) -> Any:
"""Run each field expr; if any returns a list, zip them into list-of-dicts."""
resolved: dict[str, Any] = {f: _resolve_field(data, f) for f in fields}
list_lengths = [len(v) for v in resolved.values() if isinstance(v, list)]
if not list_lengths:
return resolved
# All list values must be same length to zip cleanly.
if len(set(list_lengths)) > 1:
# Fallback: return the dict as-is so caller can inspect mismatches.
return resolved
n = list_lengths[0]
rows = []
for i in range(n):
row = {}
for f, v in resolved.items():
row[f] = v[i] if isinstance(v, list) else v
rows.append(row)
return rows
def _apply_slice(value: Any, limit: int | None, offset: int | None) -> Any:
if not isinstance(value, list):
return value
start = offset or 0
end = (start + limit) if limit is not None else None
return value[start:end]
def _format_output(value: Any, fmt: str) -> str:
if fmt == "json":
return json.dumps(value, ensure_ascii=False, indent=2)
if fmt == "jsonl":
if isinstance(value, list):
return "\n".join(json.dumps(item, ensure_ascii=False) for item in value)
return json.dumps(value, ensure_ascii=False)
if fmt in ("csv", "table"):
if not isinstance(value, list) or not value:
_err(f"--format {fmt} requires a non-empty list result")
if not all(isinstance(item, dict) for item in value):
_err(f"--format {fmt} requires list-of-objects, got list of {type(value[0]).__name__}")
keys: list[str] = []
for item in value:
for k in item.keys():
if k not in keys:
keys.append(k)
if fmt == "csv":
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=keys, extrasaction="ignore")
writer.writeheader()
for item in value:
writer.writerow({k: _stringify(item.get(k)) for k in keys})
return buf.getvalue().rstrip("\n")
# table: simple aligned columns
rows = [[_stringify(item.get(k)) for k in keys] for item in value]
widths = [len(k) for k in keys]
for row in rows:
for i, cell in enumerate(row):
widths[i] = max(widths[i], len(cell))
lines = [
" ".join(k.ljust(widths[i]) for i, k in enumerate(keys)),
" ".join("-" * widths[i] for i in range(len(keys))),
]
for row in rows:
lines.append(" ".join(row[i].ljust(widths[i]) for i in range(len(keys))))
return "\n".join(lines)
_err(f"Unknown --format: {fmt}")
return "" # unreachable
def _stringify(v: Any) -> str:
if v is None:
return ""
if isinstance(v, (dict, list)):
return json.dumps(v, ensure_ascii=False)
return str(v)
def cmd_read(args: argparse.Namespace) -> int:
if not args.path and not args.fields:
_err("read: either --path or --fields is required")
if args.path and args.fields:
_err("read: --path and --fields are mutually exclusive")
file_path = Path(args.file).expanduser().resolve()
data = _load_json(file_path)
if args.path:
result = _resolve_field(data, args.path)
else:
fields = [f.strip() for f in args.fields.split(",") if f.strip()]
if not fields:
_err("--fields parsed to empty list")
result = _project_fields(data, fields)
result = _apply_slice(result, args.limit, args.offset)
print(_format_output(result, args.format))
return 0
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(
prog="response_io.py",
description="Persist large skill API responses to disk and read fields on demand.",
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_run = sub.add_parser(
"run",
help="Execute a main script and persist its stdout to a file; "
"print only a lightweight preview to stdout.",
)
p_run.add_argument("params", help="JSON params string passed verbatim to the main script (argv[1]).")
p_run.add_argument("--script", required=True, help="Path to the main script to execute, e.g. scripts/my_api.py")
p_run.add_argument("--out-dir", required=True, help="Directory to write the response file into (created if missing).")
p_run.add_argument("--label", default=None, help="Optional filename suffix; sanitized to safe filename characters.")
p_run.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SEC, help=f"Subprocess timeout in seconds (default: {DEFAULT_TIMEOUT_SEC}).")
p_run.set_defaults(func=cmd_run)
p_read = sub.add_parser(
"read",
help="Extract specific fields from a previously persisted response file.",
)
p_read.add_argument("file", help="Path to the persisted JSON response file.")
g = p_read.add_mutually_exclusive_group()
g.add_argument("--path", default=None, help="JMESPath expression, e.g. 'data[*].{asin: asin, title: title}'.")
g.add_argument("--fields", default=None, help="Comma-separated field paths, e.g. 'data[*].asin,data[*].title'.")
p_read.add_argument("--limit", type=int, default=None, help="Take at most N items (when result is a list).")
p_read.add_argument("--offset", type=int, default=None, help="Skip the first M items (when result is a list).")
p_read.add_argument("--format", choices=["json", "jsonl", "csv", "table"], default="json", help="Output format (default: json).")
p_read.set_defaults(func=cmd_read)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
Amazon Store — searchContentDocuments (SP-API A+ Content Management v2020-11-01)
官方参考: https://developer-docs.amazon.com/sp-api/reference/searchcontentdocuments
Usage:
python search_content_documents.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER"}'
python search_content_documents.py '{"sellerId":"A1...","region":"NA","marketplaceIds":["ATVPDKIKX0DER"],"pageToken":"..."}'
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_aplus_common import (
APLUS_PATH_PREFIX,
ensure_auth_skill_available,
get_store_tokens,
developer_proxy_get,
resolve_marketplace_id,
merge_success_json,
)
def _build_query(marketplace_id: str, page_token: str | None) -> str:
parts = [f"marketplaceId={quote(marketplace_id, safe='')}"]
if page_token:
parts.append(f"pageToken={quote(page_token, safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: search_content_documents.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds)\n"
"Optional: pageToken, skipDepCheck",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("search_content_documents.py")
for f in ("sellerId", "region"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
try:
mid = resolve_marketplace_id(params, "searchContentDocuments")
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
page_token = params.get("pageToken")
if page_token is not None:
page_token = str(page_token).strip() or None
query_string = _build_query(mid, page_token)
path = f"{APLUS_PATH_PREFIX}/contentDocuments"
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_get(region, path, tokens["accessToken"], query_string)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
merge_success_json(out, proxy, "searchContentDocumentsResponse")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — searchContentPublishRecords (SP-API A+ Content Management v2020-11-01)
官方参考: https://developer-docs.amazon.com/sp-api/reference/searchcontentpublishrecords
Query 参数 asin 必填(文档 length ≥ 10)。
Usage:
python search_content_publish_records.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","asin":"B0XXXXXXXXXX"}'
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_aplus_common import (
APLUS_PATH_PREFIX,
ensure_auth_skill_available,
get_store_tokens,
developer_proxy_get,
resolve_marketplace_id,
merge_success_json,
)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: search_content_publish_records.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds), asin (length ≥ 10)\n"
"Optional: pageToken, skipDepCheck",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("search_content_publish_records.py")
for f in ("sellerId", "region", "asin"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
try:
mid = resolve_marketplace_id(params, "searchContentPublishRecords")
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
asin = str(params["asin"]).strip()
if len(asin) < 10:
print("asin must be at least 10 characters (per Amazon schema)", file=sys.stderr)
sys.exit(1)
parts = [
f"marketplaceId={quote(mid, safe='')}",
f"asin={quote(asin, safe='')}",
]
pt = params.get("pageToken")
if pt:
parts.append(f"pageToken={quote(str(pt).strip(), safe='')}")
query_string = "&".join(parts)
path = f"{APLUS_PATH_PREFIX}/contentPublishRecords"
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_get(region, path, tokens["accessToken"], query_string)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
merge_success_json(out, proxy, "searchContentPublishRecordsResponse")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — updateContentDocument (SP-API A+ Content Management v2020-11-01)
官方参考: https://developer-docs.amazon.com/sp-api/reference/updatecontentdocument
Usage:
python update_content_document.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","contentReferenceKey":"...","contentDocument":{...}}'
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_aplus_common import (
APLUS_PATH_PREFIX,
ensure_auth_skill_available,
get_store_tokens,
developer_proxy_post,
resolve_marketplace_id,
encode_path_segment,
merge_success_json,
)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: update_content_document.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds), contentReferenceKey, contentDocument\n"
"Optional: skipDepCheck",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("update_content_document.py")
for f in ("sellerId", "region", "contentReferenceKey", "contentDocument"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
try:
mid = resolve_marketplace_id(params, "updateContentDocument")
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
cd = params["contentDocument"]
if not isinstance(cd, dict):
print("contentDocument must be a JSON object", file=sys.stderr)
sys.exit(1)
key = str(params["contentReferenceKey"]).strip()
if not key:
print("contentReferenceKey must be non-empty", file=sys.stderr)
sys.exit(1)
query_string = f"marketplaceId={quote(mid, safe='')}"
path = f"{APLUS_PATH_PREFIX}/contentDocuments/{encode_path_segment(key)}"
body = {"contentDocument": cd}
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_post(
region, path, tokens["accessToken"], query_string=query_string, body_obj=body
)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
merge_success_json(out, proxy, "updateContentDocumentResponse")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — validateContentDocumentAsinRelations (SP-API A+ Content Management v2020-11-01)
官方参考: https://developer-docs.amazon.com/sp-api/reference/validatecontentdocumentasinrelations
Usage:
python validate_content_document_asin_relations.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","contentDocument":{...},"asinSet":["B0..."]}'
# asinSet 可选;也可仅放在 Query(本脚本支持 body 外再传 query asinSet)
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_aplus_common import (
APLUS_PATH_PREFIX,
ensure_auth_skill_available,
get_store_tokens,
developer_proxy_post,
resolve_marketplace_id,
merge_success_json,
)
def _append_asin_query(parts: list[str], asin_set: object | None) -> None:
if asin_set is None:
return
if isinstance(asin_set, str):
vals = [x.strip() for x in asin_set.split(",") if x.strip()]
elif isinstance(asin_set, list):
vals = [str(x).strip() for x in asin_set if str(x).strip()]
else:
raise ValueError("asinSet must be string, comma string, or array")
for a in vals:
parts.append(f"asinSet={quote(a, safe='')}")
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: validate_content_document_asin_relations.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds), contentDocument\n"
"Optional: asinSet (写入 Query,与官方文档一致), skipDepCheck",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("validate_content_document_asin_relations.py")
for f in ("sellerId", "region", "contentDocument"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
try:
mid = resolve_marketplace_id(params, "validateContentDocumentAsinRelations")
parts = [f"marketplaceId={quote(mid, safe='')}"]
_append_asin_query(parts, params.get("asinSet"))
query_string = "&".join(parts)
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
cd = params["contentDocument"]
if not isinstance(cd, dict):
print("contentDocument must be a JSON object", file=sys.stderr)
sys.exit(1)
path = f"{APLUS_PATH_PREFIX}/contentAsinValidations"
body = {"contentDocument": cd}
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_post(
region, path, tokens["accessToken"], query_string=query_string, body_obj=body
)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
merge_success_json(out, proxy, "validateContentDocumentAsinRelationsResponse")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()