
Linkfox Amazon Store Catalog
- 182 installs
- 64 repo stars
- Updated August 3, 2026
- linkfox-ai/linkfox-skills
Helps with ai & agent building tasks.
About
linkfox-amazon-store-catalog is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- linkfox-amazon-store-catalog
- AI & Agent Building
- AI-coding skill
Linkfox Amazon Store Catalog by the numbers
- 182 all-time installs (skills.sh)
- +35 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #3,035 of 16,546 AI & Agent Building 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-catalogAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 182 |
|---|---|
| repo stars | ★ 64 |
| Last updated | August 3, 2026 |
| Repository | linkfox-ai/linkfox-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Amazon 店铺 Catalog Items
本 skill 与 `linkfox-amazon-store-auth` 等同属 Amazon Store 系列:先 `POST /spApi/storeTokens`,再 `POST /spApi/developerProxy` 转发 GET。
官方参考索引
| 能力 | 文档 |
|---|---|
| listCatalogCategories | listCatalogCategories |
| searchCatalogItems | searchCatalogItems |
| getCatalogItem | getCatalogItem |
---
Prerequisites
1. 依赖 `linkfox-amazon-store-auth`;python scripts/check_auth_dependency.py,exit 42 时需先安装授权 skill。 2. 应用需具备 Catalog Items 相关角色;searchCatalogItems 按 identifiers+SKU 检索时 query 须带 `sellerId`(脚本在 identifiersType=SKU 时自动使用入参 sellerId)。
---
Current Capabilities
| 能力 | path | 脚本 |
|---|---|---|
| listCatalogCategories | catalog/v0/categories | list_catalog_categories.py |
| searchCatalogItems | `catalog/{2022-04-01\ | 2020-12-01}/items` |
| getCatalogItem | catalog/{version}/items/{asin} | get_catalog_item.py |
默认 Catalog Items 版本:`2022-04-01`;入参 `catalogItemsVersion` 可改为 `2020-12-01`。
共享模块:`_spapi_catalog_common.py`。
---
Quick Parameters
- listCatalogCategories:
marketplaceId+ `asin` 或 `sellerSku`(二选一)。 - searchCatalogItems:
marketplaceIds+ `keywords` 或 `identifiers` + `identifiersType`(互斥);可选includedData、brandNames、classificationIds、pageSize、pageToken。 - getCatalogItem:
asin、marketplaceIds;可选includedData、locale。
---
Scripts
export LINKFOXAGENT_API_KEY="<your-key>"
python scripts/list_catalog_categories.py '{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER","asin":"B08N5WRWNW"}'
python scripts/search_catalog_items.py '{"sellerId":"A1...","region":"NA","marketplaceIds":["ATVPDKIKX0DER"],"keywords":["wireless mouse"]}'
python scripts/get_catalog_item.py '{"sellerId":"A1...","region":"NA","asin":"B08N5WRWNW","marketplaceIds":["ATVPDKIKX0DER"],"includedData":["summaries","images"]}'---
Display Rules
1. 先看 `developerProxy.errcode` / `httpStatus`,再读 `categories` / `catalogItems` / `catalogItem`。 2. listCatalogCategories 使用 v0 查询键 `MarketplaceId`(单数),与 search/get 的 `marketplaceIds` 不同。 3. 网关 path 白名单需包含 `catalog/v0/` 与 `catalog/2022-04-01/`(或 2020-12-01)。
---
Important Limitations
- 本 skill 读的是 Amazon 商品目录(Catalog),不是卖家订单;订单见 `linkfox-amazon-store-orders`。
includedData、返回字段以 Amazon schema 为准,详见 `references/api.md`。
Feedback: skillName:linkfox-amazon-store-catalog。
--- 更多跨境 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,get_catalog_item.py,list_catalog_categories.py,search_catalog_items.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 -->
linkfox-amazon-store-catalog — API 参考
经 LinkFox storeTokens + developerProxy 调用 SP-API Catalog Items(与 listings / pricing 系列相同)。
环境变量:LINKFOXAGENT_API_KEY;可选 STORE_API_BASE_URL / SPAPI_BASE_URL。
---
1. 脚本与 path
| 脚本 | method | path |
|---|---|---|
list_catalog_categories.py | GET | catalog/v0/categories |
search_catalog_items.py | GET | catalog/2022-04-01/items(默认) |
get_catalog_item.py | GET | catalog/2022-04-01/items/{asin}(默认) |
catalogItemsVersion 可选 `2020-12-01`,path 中版本段随之替换。
---
2. listCatalogCategories(v0)
入参(JSON)
| 字段 | 必填 | 说明 |
|---|---|---|
| sellerId, region | 是 | 店铺与区域 |
| marketplaceId / marketplaceIds | 是 | 仅使用第一个站点 ID → query `MarketplaceId` |
| asin / ASIN | 条件 | 与 sellerSku 二选一 |
| sellerSku / SellerSKU | 条件 | 与 asin 二选一 |
Query(大小写敏感)
MarketplaceIdASIN或SellerSKU
解析字段:`categories`
---
3. searchCatalogItems
入参
| 字段 | 必填 | 说明 |
|---|---|---|
| marketplaceIds | 是 | 文档通常 ≤1 个 |
| keywords | 条件 | 与 identifiers 互斥,最多 20 个 |
| identifiers | 条件 | 最多 20 个;须配 identifiersType |
| identifiersType | 条件 | ASIN, EAN, GTIN, ISBN, JAN, MINSAN, SKU, UPC |
| includedData | 否 | summaries, images, attributes, salesRanks 等 |
| brandNames, classificationIds | 否 | 限缩关键词搜索 |
| locale, keywordsLocale | 否 | |
| pageSize | 否 | 1~20,默认 10 |
| pageToken | 否 | 分页 |
| catalogItemsVersion | 否 | 2022-04-01(默认)或 2020-12-01 |
| sellerIdForCatalog | 否 | 覆盖 query 中的 sellerId(默认用 sellerId) |
当 identifiersType=SKU 时,Amazon 要求 query 带 sellerId;脚本默认使用 JSON 里的 sellerId。
解析字段:`catalogItems`
---
4. getCatalogItem
| 字段 | 必填 | 说明 |
|---|---|---|
| asin | 是 | path 段 |
| marketplaceIds | 是 | |
| includedData | 否 | |
| locale | 否 | |
| catalogItemsVersion | 否 |
解析字段:`catalogItem`
---
5. includedData 示例(2022-04-01)
summaries, attributes, classifications, dimensions, identifiers, images, productTypes, relationships, salesRanks, vendorDetails(以官方为准)。
---
6. 错误与白名单
- 403:Catalog Items 权限不足。
- 1005(网关):需放行
catalog/v0/、catalog/2020-12-01/、catalog/2022-04-01/。 - 429:按官方 usage plan 降频。
---
7. Feedback
上报时注明 `skillName`: `linkfox-amazon-store-catalog`。
"""Shared helpers for linkfox-amazon-store-catalog (Catalog Items API)."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Iterable, 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
CATALOG_V0 = "v0"
CATALOG_ITEMS_V2022 = "2022-04-01"
CATALOG_ITEMS_V2020 = "2020-12-01"
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"
SUCCESS_HTTP_STATUSES = frozenset({200})
def ensure_auth_skill_available(caller: str = "catalog script") -> 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}",
}
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 = {
"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 encode_path_segment(value: str) -> str:
return quote(str(value).strip(), safe="")
def str_list(val: object, name: str) -> list[str]:
if val is None:
return []
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()]
print(f"{name} must be a string or string[]", file=sys.stderr)
sys.exit(1)
def norm_marketplace_ids(params: dict, *, max_count: Optional[int] = None) -> list[str]:
mids = params.get("marketplaceIds")
if mids is None:
mid = params.get("marketplaceId")
if mid is not None:
mids = [str(mid).strip()]
else:
return []
else:
mids = str_list(mids, "marketplaceIds")
if max_count is not None and len(mids) > max_count:
print(f"marketplaceIds length must be ≤ {max_count}", file=sys.stderr)
sys.exit(1)
return mids
def resolve_catalog_items_version(params: dict) -> str:
ver = str(params.get("catalogItemsVersion") or CATALOG_ITEMS_V2022).strip()
if ver not in (CATALOG_ITEMS_V2022, CATALOG_ITEMS_V2020):
print(
f"catalogItemsVersion must be {CATALOG_ITEMS_V2022} or {CATALOG_ITEMS_V2020}",
file=sys.stderr,
)
sys.exit(1)
return ver
def catalog_items_path(version: str, suffix: str = "items") -> str:
return f"catalog/{version}/{suffix}"
def merge_success_json(
out: dict,
proxy: dict,
result_key: str,
*,
success_http: Iterable[int] = SUCCESS_HTTP_STATUSES,
) -> None:
if proxy.get("errcode") != 200:
return
try:
status = int(proxy.get("httpStatus") or 0)
except (TypeError, ValueError):
return
if status not in success_http:
return
body_raw = proxy.get("body")
if body_raw is None or not str(body_raw).strip():
out[result_key] = None
return
try:
out[result_key] = json.loads(str(body_raw))
except json.JSONDecodeError:
out[result_key] = None
out[f"{result_key}Raw"] = body_raw
def load_cli_params() -> dict:
if len(sys.argv) < 2:
return {}
try:
return json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
def require_seller_region(params: dict) -> tuple[str, str]:
for f in ("sellerId", "region"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
return str(params["sellerId"]), str(params["region"])
#!/usr/bin/env python3
"""
Dependency Check - linkfox-amazon-store-catalog
==============================================
与 `linkfox-amazon-store-listings` / `linkfox-amazon-store-pricing` 相同逻辑:
探测是否已安装 `linkfox-amazon-store-auth`。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
REQUIRED_SKILL = "linkfox-amazon-store-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]:
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]:
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",
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
if category_dir.name.startswith(".") or category_dir.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
found = _hermes_category_skill_md(home / ".hermes" / "skills", 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-catalog 依赖 `{REQUIRED_SKILL}`,"
"但在常见 skill 安装路径下未找到其 SKILL.md。"
),
"searchedRoots": searched_locations_for_report(),
"suggestedActions": [
f"If a skill installer tool is available, install '{REQUIRED_SKILL}' immediately.",
"Otherwise ask the user to install from https://skill.linkfox.com/ and retry.",
],
"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 — getCatalogItem (SP-API Catalog Items v2022-04-01 默认)
======================================================================
GET `catalog/{version}/items/{asin}`。默认 **2022-04-01**。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getcatalogitem
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_catalog_common import (
CATALOG_ITEMS_V2020,
CATALOG_ITEMS_V2022,
catalog_items_path,
developer_proxy_get,
encode_path_segment,
ensure_auth_skill_available,
get_store_tokens,
load_cli_params,
merge_success_json,
norm_marketplace_ids,
require_seller_region,
resolve_catalog_items_version,
str_list,
)
def _build_query(params: dict) -> str:
mids = norm_marketplace_ids(params, max_count=1)
if not mids:
print("Missing marketplaceIds (or marketplaceId).", file=sys.stderr)
sys.exit(1)
parts: list[str] = [f"marketplaceIds={quote(mids[0], safe='')}"]
for inc in str_list(params.get("includedData"), "includedData"):
parts.append(f"includedData={quote(inc, safe='')}")
if params.get("locale"):
parts.append(f"locale={quote(str(params['locale']).strip(), safe='')}")
return "&".join(parts)
def main() -> None:
params = load_cli_params()
if not params:
print(
"Usage: get_catalog_item.py '<JSON>'\n"
"Required: sellerId, region, asin, marketplaceIds\n"
f"Optional: includedData, locale, catalogItemsVersion ({CATALOG_ITEMS_V2022}|{CATALOG_ITEMS_V2020})",
file=sys.stderr,
)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("get_catalog_item.py")
if "asin" not in params:
print("Missing required field: asin", file=sys.stderr)
sys.exit(1)
seller_id, region = require_seller_region(params)
version = resolve_catalog_items_version(params)
asin = encode_path_segment(params["asin"])
path = f"{catalog_items_path(version)}/{asin}"
qs = _build_query(params)
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=qs)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": qs,
"catalogItemsVersion": version,
}
merge_success_json(out, proxy, "catalogItem")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — listCatalogCategories (SP-API Catalog Items v0)
==============================================================
GET `catalog/v0/categories`。须传 **MarketplaceId** 以及 **ASIN** 或 **SellerSKU** 之一。
官方参考: https://developer-docs.amazon.com/sp-api/reference/listcatalogcategories
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_catalog_common import (
developer_proxy_get,
ensure_auth_skill_available,
get_store_tokens,
load_cli_params,
merge_success_json,
norm_marketplace_ids,
require_seller_region,
)
PATH = "catalog/v0/categories"
def _build_query(params: dict) -> str:
mids = norm_marketplace_ids(params, max_count=1)
if not mids:
print("Missing marketplaceId or marketplaceIds (use one id)", file=sys.stderr)
sys.exit(1)
if len(mids) > 1:
print("listCatalogCategories uses a single MarketplaceId; using first only.", file=sys.stderr)
asin = params.get("asin") or params.get("ASIN")
sku = params.get("sellerSku") or params.get("SellerSKU")
if asin and sku:
print("Provide only one of asin/ASIN or sellerSku/SellerSKU.", file=sys.stderr)
sys.exit(1)
if not asin and not sku:
print("Required: asin (or ASIN) OR sellerSku (or SellerSKU).", file=sys.stderr)
sys.exit(1)
parts = [f"MarketplaceId={quote(mids[0], safe='')}"]
if asin:
parts.append(f"ASIN={quote(str(asin).strip(), safe='')}")
else:
parts.append(f"SellerSKU={quote(str(sku).strip(), safe='')}")
return "&".join(parts)
def main() -> None:
params = load_cli_params()
if not params:
print(
"Usage: list_catalog_categories.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId(s), and asin OR sellerSku",
file=sys.stderr,
)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("list_catalog_categories.py")
seller_id, region = require_seller_region(params)
qs = _build_query(params)
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=qs)
out: dict = {"developerProxy": proxy, "resolvedPath": PATH, "queryString": qs}
merge_success_json(out, proxy, "categories")
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 — searchCatalogItems (SP-API Catalog Items v2022-04-01 默认)
==========================================================================
GET `catalog/{version}/items`。默认 **2022-04-01**;可设 `catalogItemsVersion` 为 **2020-12-01**。
须 **marketplaceIds**(文档通常 ≤1),且 **keywords** 与 **identifiers+identifiersType** 二选一。
官方参考: https://developer-docs.amazon.com/sp-api/reference/searchcatalogitems
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_catalog_common import (
CATALOG_ITEMS_V2020,
CATALOG_ITEMS_V2022,
catalog_items_path,
developer_proxy_get,
ensure_auth_skill_available,
get_store_tokens,
load_cli_params,
merge_success_json,
norm_marketplace_ids,
require_seller_region,
resolve_catalog_items_version,
str_list,
)
IDENTIFIERS_TYPE_ENUM = frozenset(
{"ASIN", "EAN", "GTIN", "ISBN", "JAN", "MINSAN", "SKU", "UPC"}
)
def _build_query(params: dict) -> str:
mids = norm_marketplace_ids(params, max_count=1)
if not mids:
print("Missing marketplaceIds (or marketplaceId).", file=sys.stderr)
sys.exit(1)
keywords = str_list(params.get("keywords"), "keywords")
identifiers = str_list(params.get("identifiers"), "identifiers")
id_type = params.get("identifiersType")
if keywords and identifiers:
print("keywords and identifiers cannot be used together.", file=sys.stderr)
sys.exit(1)
if not keywords and not identifiers:
print("Required: keywords OR (identifiers + identifiersType).", file=sys.stderr)
sys.exit(1)
if identifiers and not id_type:
print("identifiersType is required when identifiers is set.", file=sys.stderr)
sys.exit(1)
if id_type and str(id_type).strip().upper() not in IDENTIFIERS_TYPE_ENUM:
print(f"identifiersType must be one of: {sorted(IDENTIFIERS_TYPE_ENUM)}", file=sys.stderr)
sys.exit(1)
if len(keywords) > 20:
print("keywords length must be ≤ 20", file=sys.stderr)
sys.exit(1)
if len(identifiers) > 20:
print("identifiers length must be ≤ 20", file=sys.stderr)
sys.exit(1)
parts: list[str] = []
def add(k: str, v: str) -> None:
parts.append(f"{k}={quote(v, safe='')}")
for mid in mids:
add("marketplaceIds", mid)
for kw in keywords:
add("keywords", kw)
for ident in identifiers:
add("identifiers", ident)
if id_type:
add("identifiersType", str(id_type).strip())
for inc in str_list(params.get("includedData"), "includedData"):
add("includedData", inc)
for bn in str_list(params.get("brandNames"), "brandNames"):
add("brandNames", bn)
for cid in str_list(params.get("classificationIds"), "classificationIds"):
add("classificationIds", cid)
if params.get("locale"):
add("locale", str(params["locale"]).strip())
if params.get("keywordsLocale"):
add("keywordsLocale", str(params["keywordsLocale"]).strip())
if params.get("sellerIdForCatalog"):
add("sellerId", str(params["sellerIdForCatalog"]).strip())
elif params.get("identifiersType") == "SKU" or str(params.get("identifiersType", "")).upper() == "SKU":
sid = params.get("sellerId")
if sid:
add("sellerId", str(sid).strip())
if params.get("pageSize") is not None:
ps = int(params["pageSize"])
if ps < 1 or ps > 20:
print("pageSize must be between 1 and 20", file=sys.stderr)
sys.exit(1)
add("pageSize", str(ps))
if params.get("pageToken"):
add("pageToken", str(params["pageToken"]).strip())
return "&".join(parts)
def main() -> None:
params = load_cli_params()
if not params:
print(
"Usage: search_catalog_items.py '<JSON>'\n"
"Required: sellerId, region, marketplaceIds, and keywords OR identifiers+identifiersType\n"
"Optional: includedData, brandNames, classificationIds, locale, pageSize, pageToken, "
f"catalogItemsVersion ({CATALOG_ITEMS_V2022}|{CATALOG_ITEMS_V2020})",
file=sys.stderr,
)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("search_catalog_items.py")
seller_id, region = require_seller_region(params)
version = resolve_catalog_items_version(params)
path = catalog_items_path(version)
qs = _build_query(params)
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=qs)
out: dict = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": qs,
"catalogItemsVersion": version,
}
merge_success_json(out, proxy, "catalogItems")
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()