
Linkfox Amazon Store Listings
- 182 installs
- 64 repo stars
- Updated August 3, 2026
- linkfox-ai/linkfox-skills
Helps with ai & agent building tasks.
About
linkfox-amazon-store-listings is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- linkfox-amazon-store-listings
- AI & Agent Building
- AI-coding skill
Linkfox Amazon Store Listings 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-listingsAdd 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 店铺 Listings 与相关 API
本 skill 与 `linkfox-amazon-store-auth`、`linkfox-amazon-store-report` 同属 Amazon Store 系列:使用 `POST /spApi/storeTokens` 取 accessToken,再经 `POST /spApi/developerProxy` 转发上游 GET、PATCH、PUT 或 DELETE。
| 操作 | 官方参考 |
|---|---|
| 单条刊登 | getListingsItem |
| 检索列表 | searchListingsItems |
| 部分更新刊登 | patchListingsItem |
| 创建 / 全量更新刊登 | putListingsItem |
| 删除刊登 | deleteListingsItem |
| 刊登限制(ASIN) | getListingsRestrictions |
| 搜索 product type | searchDefinitionsProductTypes |
| 获取 product type 定义 | getDefinitionsProductType |
---
Prerequisites(必须先读)
本 skill 依赖 `linkfox-amazon-store-auth`。流程与 linkfox-amazon-store-report 相同:
1. 运行 python scripts/check_auth_dependency.py;若 exit code 42 且 stderr 含 DEPENDENCY_MISSING:,请先安装 `linkfox-amazon-store-auth`。 2. 不要在本 skill 内绕过依赖实现授权或令牌逻辑。
---
Current Capabilities
| 能力 | Path 要点 | 脚本 |
|---|---|---|
| 获取单条 Listing | listings/2021-08-01/items/{sellerId}/{sku} + Query | scripts/get_listings_item.py |
| 搜索刊登列表 | listings/2021-08-01/items/{sellerId} + Query(无路径尾段 SKU) | scripts/search_listings_items.py |
| 部分更新 Listing(PATCH) | 同 get 路径 + Query + JSON body | scripts/patch_listings_item.py |
| 创建 / 全量更新 Listing(PUT) | 同 get 路径 + Query + body;marketplaceIds 仅 1 个 | scripts/put_listings_item.py |
| 删除 Listing(DELETE) | 同 get 路径 + Query(marketplaceIds 仅 1 个) | scripts/delete_listings_item.py |
| 刊登限制 | listings/2021-08-01/restrictions + Query(asin、sellerId、marketplaceIds 等) | scripts/get_listings_restrictions.py |
| 搜索 Product Type | definitions/2020-09-01/productTypes + Query | scripts/search_definitions_product_types.py |
| 获取 Product Type 定义 | definitions/2020-09-01/productTypes/{productType} + Query;marketplaceIds 仅 1 个 | scripts/get_definitions_product_type.py |
searchListingsItems 下 identifiers / variationParentSku / packageHierarchySku 三者互斥(仅该接口;见 `references/api.md`)。searchDefinitionsProductTypes 下 `keywords` 与 `itemName` 互斥。
---
Quick Parameters
getListingsItem(单条)
| 字段 | 必填 | 说明 |
|---|---|---|
| sellerId | 是 | 已授权 Seller ID |
| region | 是 | NA / EU / FE |
| sku | 是 | 卖家 SKU(非 ASIN) |
| marketplaceIds | 是 | 建议仅 一个 marketplace id |
| includedData / issueLocale | 否 | 见 references/api.md |
searchListingsItems(列表)
| 字段 | 必填 | 说明 |
|---|---|---|
| sellerId | 是 | 已授权 Seller ID |
| region | 是 | NA / EU / FE |
| marketplaceIds | 是 | 建议仅 一个 marketplace id |
| identifiers + identifiersType | 否 | 最多 20 个;与 variationParentSku、packageHierarchySku 不能同用 |
| variationParentSku / packageHierarchySku | 否 | 与 identifiers 互斥 |
| 时间窗 / 状态 / 排序 / 分页 | 否 | createdAfter、lastUpdatedBefore、withStatus、sortBy、pageSize(≤20)、pageToken 等,见 `references/api.md` |
patchListingsItem(部分更新)
| 字段 | 必填 | 说明 |
|---|---|---|
| sellerId | 是 | 已授权 Seller ID |
| region | 是 | NA / EU / FE |
| sku | 是 | 卖家 SKU |
| marketplaceIds | 是 | 数组或逗号字符串;脚本拼入 Query |
| productType | 是 | Amazon product type |
| patches | 是 | 至少 1 条 JSON Patch(op、path 等) |
| includedData / mode / issueLocale | 否 | 见 `references/api.md` |
putListingsItem(创建 / 全量更新)
| 字段 | 必填 | 说明 |
|---|---|---|
| sellerId | 是 | 已授权 Seller ID |
| region | 是 | NA / EU / FE |
| sku | 是 | 卖家 SKU |
| marketplaceIds | 是 | 恰好一个 marketplace id |
| productType | 是 | Amazon product type |
| requirements | 是 | LISTING \ |
| attributes | 是 | 须符合该 product type schema |
| includedData / mode / issueLocale | 否 | 见 `references/api.md` |
deleteListingsItem(删除)
| 字段 | 必填 | 说明 |
|---|---|---|
| sellerId | 是 | 已授权 Seller ID |
| region | 是 | NA / EU / FE |
| sku | 是 | 卖家 SKU |
| marketplaceIds | 是 | 恰好一个 marketplace id |
| issueLocale | 否 | 见 `references/api.md` |
getListingsRestrictions
| 字段 | 必填 | 说明 |
|---|---|---|
| sellerId | 是 | 用于 storeTokens 与 Query |
| region | 是 | NA / EU / FE |
| asin | 是 | 目录 ASIN |
| marketplaceIds | 是 | 数组或逗号字符串 |
| conditionType / reasonLocale | 否 | 见 `references/api.md` |
searchDefinitionsProductTypes
| 字段 | 必填 | 说明 |
|---|---|---|
| sellerId | 是 | 用于 storeTokens |
| region | 是 | NA / EU / FE |
| marketplaceIds | 是 | 数组或逗号字符串 |
| keywords 或 itemName | 否 | 二者不可同时传(非空时互斥) |
| locale / searchLocale | 否 | 见 `references/api.md` |
getDefinitionsProductType
| 字段 | 必填 | 说明 |
|---|---|---|
| sellerId | 是 | 用于 storeTokens |
| region | 是 | NA / EU / FE |
| productType | 是 | 如 LUGGAGE(写入 path) |
| marketplaceIds | 是 | 恰好一个 id |
| querySellerId | 否 | 若需上游 Query `sellerId`(卖家专属 schema),传入;常与 sellerId 相同 |
| productTypeVersion / requirements / requirementsEnforced / locale | 否 | 见 `references/api.md` |
---
Scripts
- `scripts/get_listings_item.py` — 单条 listing。
- `scripts/search_listings_items.py` — 搜索列表。
- `scripts/patch_listings_item.py` — PATCH 部分更新。
- `scripts/put_listings_item.py` — PUT 创建或全量更新。
- `scripts/delete_listings_item.py` — DELETE 删除刊登。
- `scripts/get_listings_restrictions.py` — GET 刊登限制。
- `scripts/search_definitions_product_types.py` — GET 搜索 product type。
- `scripts/get_definitions_product_type.py` — GET product type JSON Schema。
- `scripts/check_auth_dependency.py` — 依赖检测。
示例:
export LINKFOXAGENT_API_KEY="<your-key>"
python scripts/get_listings_item.py '{"sellerId":"A1...","region":"NA","sku":"MY-SKU","marketplaceIds":["ATVPDKIKX0DER"]}'
python scripts/search_listings_items.py '{"sellerId":"A1...","region":"NA","marketplaceIds":["ATVPDKIKX0DER"],"identifiers":["B0XXXXXXXX"],"identifiersType":"ASIN"}'
python scripts/patch_listings_item.py '{"sellerId":"A1...","region":"NA","sku":"MY-SKU","marketplaceIds":["ATVPDKIKX0DER"],"productType":"LUGGAGE","patches":[{"op":"replace","path":"/attributes/item_name","value":[{"value":"New Title","marketplace_id":"ATVPDKIKX0DER"}]}]}'
python scripts/put_listings_item.py '{"sellerId":"A1...","region":"NA","sku":"MY-SKU","marketplaceIds":["ATVPDKIKX0DER"],"productType":"LUGGAGE","requirements":"LISTING","attributes":{"item_name":[{"value":"Title","marketplace_id":"ATVPDKIKX0DER"}]}}'
python scripts/delete_listings_item.py '{"sellerId":"A1...","region":"NA","sku":"MY-SKU","marketplaceIds":["ATVPDKIKX0DER"]}'
python scripts/get_listings_restrictions.py '{"sellerId":"A1...","region":"NA","asin":"B0XXXXXXXX","marketplaceIds":["ATVPDKIKX0DER"]}'
python scripts/search_definitions_product_types.py '{"sellerId":"A1...","region":"NA","marketplaceIds":["ATVPDKIKX0DER"],"keywords":["luggage"]}'
python scripts/get_definitions_product_type.py '{"sellerId":"A1...","region":"NA","productType":"LUGGAGE","marketplaceIds":["ATVPDKIKX0DER"],"querySellerId":"A1..."}'---
Display Rules
1. items 类接口:路径含 卖家 SKU 时强调非 ASIN;searchListingsItems 路径仅到 sellerId。 2. 展示网关结果时说明 `errcode` / `httpStatus`;成功后再解析 body(listing / searchResult / patchResult / putResult / deleteResult / `restrictionsResult` / `productTypesSearchResult` / `productTypeDefinitionResult`)。 3. searchListingsItems 多页:从 searchResult 取下一页 token(字段名以 Amazon 响应为准),传入 pageToken。 4. restrictions / definitions 路径与 items 不同;1005 时需为 `listings/2021-08-01/restrictions` 与 `definitions/2020-09-01/productTypes` 等前缀分别配置白名单(以运维为准)。 5. patch / put / delete 为写操作;delete 须谨慎;put 为全量 `attributes`;patch 仅顶层属性可 patch(以官方为准)。
---
Important Limitations
- marketplaceIds:get / searchListingsItems 脚本对多 id 常仅取第一个;put / delete / getDefinitionsProductType 多于 1 个即报错;patch、restrictions、searchDefinitionsProductTypes 行为见 `references/api.md`。
- searchListingsItems:
pageSize≤ 20;identifiers≤ 20。 - patch:
patches至少 1 条;JSON Patch 的 `delete` 与 deleteListingsItem 接口不同;Vendor 对部分 patch 可能 400。 - put:LISTING_OFFER_ONLY 对 Vendor 400。
- 白名单:
listings/.../items、listings/.../restrictions、definitions/2020-09-01/productTypes均须按需放行。
Feedback: 见 references/api.md 中 Feedback API,skillName:linkfox-amazon-store-listings。
--- 更多跨境 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,delete_listings_item.py,get_definitions_product_type.py,get_listings_item.py,get_listings_restrictions.py,patch_listings_item.py,put_listings_item.py,search_definitions_product_types.py,search_listings_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 -->
Amazon 店铺 Listings API 参考(Listings Items / Restrictions / Product Type Definitions)
本文档描述通过 LinkFox 店铺网关 调用 Selling Partner API 中与刊登相关的接口:Listings Items v2021-08-01(getListingsItem、searchListingsItems、patchListingsItem、putListingsItem、deleteListingsItem)、Listings Restrictions v2021-08-01(getListingsRestrictions)、Product Type Definitions v2020-09-01(searchDefinitionsProductTypes、getDefinitionsProductType)。流程与 linkfox-amazon-store-report 完全一致——先取店铺令牌,再经 `POST /spApi/developerProxy` 转发上游 HTTP。
官方参考:getListingsItem · searchListingsItems · patchListingsItem · putListingsItem · deleteListingsItem · getListingsRestrictions · searchDefinitionsProductTypes · getDefinitionsProductType(路径、Query、Body、响应以 Amazon 文档为准)。
⚠️ 依赖:调用前需已安装并完成授权流程 `linkfox-amazon-store-auth`(/spApi/storeTokens等),参见本 skill 的SKILL.md。
---
调用规范(与 store-report 相同)
| 项 | 说明 |
|---|---|
| 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,Body 见下节 |
---
通用接口:POST /spApi/developerProxy
与 linkfox-amazon-store-report 的 references/api.md 中 Developer Proxy 定义相同。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| region | string | 是 | NA / EU / FE,须与店铺授权区域一致 |
| path | string | 是 | 不含 SP-API 主机名前缀,仅 资源路径(见下文 Listings items / restrictions、definitions/.../productTypes 等模板) |
| method | string | 是 | `GET`(读操作)、`PATCH`、`PUT` 或 `DELETE`(写操作) |
| amzAccessToken | string | 是 | /spApi/storeTokens 返回的 accessToken |
| queryString | string | 视操作 | 无 `?` 前缀。各操作必填/可选 Query 见各节(put / delete / getDefinitionsProductType 的 marketplaceIds 等见官方 length ≤ 1 说明) |
| body | string | 视操作 | PATCH / PUT 时 JSON 字符串。GET / DELETE 通常不传 |
| contentType | string | 视操作 | PATCH / PUT 建议 `application/json`。GET / DELETE 通常省略 |
网关响应(与报告 skill 一致):
{
"errcode": 200,
"errmsg": "ok",
"httpStatus": 200,
"contentType": "application/json",
"body": "{... Amazon 返回的 JSON 字符串 ...}"
}解析步骤:先看 `errcode`,再 `httpStatus`,最后将 `body` 做 JSON.parse。
白名单与错误码
path必须在后端 `sp-api.developer-proxy.allowed-path-prefixes` 白名单内。若 `errcode=1005`,需联系后端放行(常见前缀,以运维为准):- `listings/2021-08-01/items`:
get/patch/put/delete为.../items/{sellerId}/{sku},searchListingsItems为.../items/{sellerId}; - `listings/2021-08-01/restrictions`:
getListingsRestrictions(无 path 变量,固定.../restrictions); - `definitions/2020-09-01/productTypes`:
searchDefinitionsProductTypes与 `getDefinitionsProductType`(后者 path 含 `/{productType}` 段)。 - 网关须允许 `PATCH`、`PUT`、`DELETE`(及已有 GET)转发。
- 其它 `errcode` / `httpStatus` 含义与处置与
linkfox-amazon-store-report的references/api.md中 Error Codes / Developer Proxy 上游状态码 表格一致(400 参数、403 令牌/权限、404 无此 SKU 等)。
---
getListingsItem — 上游语义与网关映射
HTTP 与方法(Amazon)
- Method:
GET - 资源路径模板(写入
developerProxy.path):
listings/2021-08-01/items/{sellerId}/{sku}其中 `{sellerId}`、`{sku}` 须做 URL path 分段编码(例如空格、/ 等字符);本仓库脚本使用百分号编码后拼入 path。
说明:不同区域 SP-API 主机名不同(na/eu/fe 等),由网关按region+amzAccessToken解析,调用方只传相对 path 即可。
Path 参数(写入 path 的占位段)
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
| sellerId | string | 是 | 卖家标识,与已授权店铺一致(与 /spApi/storeTokens 所用 sellerId 相同) |
| sku | string | 是 | 卖家自定义 SKU(Seller SKU),不是 ASIN。须与 Listing 在目标站点下实际存在的 seller SKU 一致 |
Query 参数(写入 developerProxy.queryString,无 ?)
以下按 Amazon 文档整理;键名大小写须与官方一致。
| 参数名 | 类型 | 必填 | 约束 / 说明 |
|---|---|---|---|
| marketplaceIds | string | 是 | 目标 marketplace id。官方 schema 常见为 单站点(文档标注 length ≤ 1 时应只传 一个 id)。多个 id 时本仓库脚本 仅取第一个 并打 stderr 警告。示例:ATVPDKIKX0DER(美国)。Query 中写法:marketplaceIds=ATVPDKIKX0DER(单值;若网关要求重复键名以官方为准,一般单站点即可) |
| includedData | string | 否 | 逗号分隔数据集列表,控制响应中包含哪些块。不传时上游通常等价于默认 `summaries`(以实网为准)。可选值(与官方一致):summaries、attributes、issues、offers、fulfillmentAvailability、procurement、relationships、productTypes。示例:includedData=summaries,attributes |
| issueLocale | string | 否 | issues 等字段的本地化语言。示例:en_US、fr_CA。未传时通常使用第一个 marketplace 的默认语言;不可用回退行为以 Amazon 为准 |
官方默认与速率(摘要)
- includedData 默认集:文档为 `summaries`(若需结构化属性、报价、库存等,请在
includedData中显式增加对应 token)。 - Usage plan(文档默认值):约 5 req/s,burst 10(以响应头
x-amzn-RateLimit-Limit及账号实际配额为准)。
上游成功 / 常见错误(httpStatus)
| httpStatus | 含义(摘要) |
|---|---|
| 200 | 成功,body 为 Listing 详情 JSON |
| 400 | Query / Path 非法或无法解析 |
| 403 | 拒绝访问(令牌、签名、权限等) |
| 404 | 指定 sellerId+sku+marketplaceIds 下无该 listing |
| 413 | 请求过大 |
| 415 | 不支持的媒体类型 |
| 429 | 限流 |
| 500 / 503 | 上游异常或维护 |
---
脚本参数映射(scripts/get_listings_item.py)
| 脚本 JSON 字段 | 映射到 developerProxy |
|---|---|
| sellerId | 用于 storeTokens;并编码进 path 第一段 |
| region | region |
| sku | 编码进 path 第二段 |
| marketplaceIds | array[string] 或单个 string → queryString.marketplaceIds(多元素时仅首元素) |
| includedData | 可选,array 用逗号拼接 → queryString.includedData |
| issueLocale | 可选 → queryString.issueLocale |
脚本在 stdout 打印 JSON:含 developerProxy 原始包、resolvedPath、queryString;若 errcode==200 且 httpStatus==200,额外解析 listing 对象(body 的 JSON)。
---
searchListingsItems — 上游语义与网关映射
官方:searchListingsItems
HTTP 与方法
- Method:
GET - 资源路径(写入
developerProxy.path,仅含sellerId,无路径尾段的 SKU):
listings/2021-08-01/items/{sellerId}{sellerId} 须 URL 编码(与 getListingsItem 相同约定)。
Path 参数
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
| sellerId | string | 是 | 卖家标识,与已授权店铺及 storeTokens 一致 |
Query 参数(写入 queryString)
| 参数名 | 类型 | 必填 | 约束 / 说明 |
|---|---|---|---|
| marketplaceIds | string | 是 | 文档为 length ≤ 1 的 marketplace id 列表(逗号分隔语义);本仓库脚本与 get 一致:多传时仅取第一个 并告警。示例:ATVPDKIKX0DER |
| issueLocale | string | 否 | 本地化 issues 等,如 en_US、fr_CA;未传时行为以 Amazon 为准 |
| includedData | string | 否 | 逗号分隔,默认文档为 `summaries`。可选:summaries、attributes、issues、offers、fulfillmentAvailability、procurement、relationships、productTypes |
| identifiers | string | 条件 | 最多 20 个商品标识,逗号分隔(脚本接受 JSON 数组,输出为逗号拼接)。与 `identifiersType` 成对出现:传了 identifiers 必须带 identifiersType,反之亦然 |
| identifiersType | string | 条件 | 标识类型枚举:SKU、ASIN、EAN、FNSKU、GTIN、ISBN、JAN、MINSAN、UPC(以官方为准) |
| variationParentSku | string | 条件 | 只返回该 父 SKU 下的变体子体。禁止与 identifiers / packageHierarchySku 同时使用 |
| packageHierarchySku | string | 条件 | 返回包含或由该 SKU 构成的层级相关 listing。禁止与 identifiers / variationParentSku 同时使用 |
| createdAfter | string | 否 | ISO 8601 date-time,筛选创建时间 ≥ 该时刻 |
| createdBefore | string | 否 | ISO 8601,创建时间 ≤ 该时刻 |
| lastUpdatedAfter | string | 否 | ISO 8601,最后更新时间 ≥ 该时刻 |
| lastUpdatedBefore | string | 否 | ISO 8601,最后更新时间 ≤ 该时刻 |
| withIssueSeverity | string | 否 | 逗号分隔;取值:ERROR、WARNING(仅包含有对应严重度 issue 的刊登) |
| withStatus | string | 否 | 逗号分隔;取值:BUYABLE、DISCOVERABLE(文档注明 BUYABLE 不适用于 vendor listings) |
| withoutStatus | string | 否 | 逗号分隔;同上枚举,表示 排除 含该状态的刊登 |
| sortBy | string | 否 | 默认文档 `lastUpdatedDate`。允许:sku、createdDate、lastUpdatedDate |
| sortOrder | string | 否 | 默认 `DESC`。允许:ASC、DESC |
| pageSize | integer | 否 | 默认 10,最大 20(超出时脚本会截断为 20 并告警) |
| pageToken | string | 否 | 下一页令牌(上一页响应中的分页字段,以 body JSON 为准) |
互斥规则(官方 Note)
以下 不能混用(本仓库 search_listings_items.py 会做校验):
1. 使用 `identifiers` / `identifiersType` 时,不得再传 variationParentSku 或 packageHierarchySku。 2. 使用 `variationParentSku` 时,不得使用 identifiers(及 identifiersType)或 packageHierarchySku。 3. 使用 `packageHierarchySku` 时,不得使用 identifiers 或 variationParentSku。 4. `variationParentSku` 与 `packageHierarchySku` 亦不得同时使用。
速率(文档摘要)
- 默认约 5 req/s,burst 5(以
x-amzn-RateLimit-Limit与账号配额为准;与 getListingsItem 的 burst 可能不同)。
成功响应
- `httpStatus=200` 时,
body为 JSON 字符串,通常含 列表 + 分页(字段名以 Amazon 响应为准,常见含items、numberOfResults、pagination等)。
---
脚本参数映射(scripts/search_listings_items.py)
| 脚本 JSON 字段 | 映射 |
|---|---|
| sellerId | storeTokens + path 编码 |
| region | region |
| marketplaceIds | 同 get;→ marketplaceIds |
| issueLocale / includedData | → Query |
| identifiers | array 或逗号字符串,≤20 个 → identifiers |
| identifiersType | → identifiersType |
| variationParentSku / packageHierarchySku | → 同名 Query |
| createdAfter / createdBefore / lastUpdatedAfter / lastUpdatedBefore | → 同名 Query |
| withIssueSeverity / withStatus / withoutStatus | array 或逗号字符串 → 逗号拼接 |
| sortBy / sortOrder / pageSize / pageToken | → 同名 Query |
成功且 errcode==200、httpStatus==200 时,脚本 stdout 中额外包含 `searchResult`(body 解析后的对象)。
---
patchListingsItem — 上游语义与网关映射
官方:patchListingsItem
仅支持对 顶层 listing 属性做 JSON Patch;嵌套属性 patch 行为以 Amazon 文档为准。Vendor 场景下 `delete` 可能返回 400(官方说明)。
HTTP 与方法
- Method:
PATCH(经developerProxy.method传入 `PATCH`) - Path(与 getListingsItem 相同,含 sellerId 与 卖家 SKU):
listings/2021-08-01/items/{sellerId}/{sku}两段路径均需 URL 编码。
Query 参数(queryString)
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| marketplaceIds | string | 是 | 逗号分隔的 marketplace id 列表(脚本接受 JSON 数组并拼为逗号)。与 get 不同,patch 文档为 列表;多站点时一并传入 |
| includedData | string | 否 | 逗号分隔;默认文档为 `issues`。可选:identifiers、issues。文档说明:`identifiers` 仅在 `mode=VALIDATION_PREVIEW` 时可请求 |
| mode | string | 否 | 枚举:`VALIDATION_PREVIEW`(校验预览模式,与 includedData=identifiers 等组合以官方为准) |
| issueLocale | string | 否 | 如 en_US、fr_CA;issues 本地化 |
Body(developerProxy.body,JSON 字符串)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| productType | string | 是 | Amazon product type(如 LUGGAGE、PRODUCT 等,须与该 listing 在目录中的类型一致,以 Product Type Definitions 为准) |
| patches | array | 是 | 至少 1 条 JSON Patch 操作(Amazon 支持子集) |
单条 patch 对象(patches[]):
| 字段 | 必填 | 说明 |
|---|---|---|
| op | 是 | add \ |
| path | 是 | JSON Pointer,指向待修改属性(如 /attributes/item_name) |
| value | 视 op | add / replace / merge 通常需要;delete 可无(Vendor 可能不支持 delete) |
速率(文档摘要)
- 默认约 5 req/s,burst 5(以响应头与账号配额为准)。
响应(httpStatus)
- 200:已理解 patch 请求;是否被接受须解析 `body` 内 issues / status(以 Amazon 响应 schema 为准)。
- 400 / 403 / 413 / 415 / 429 / 500 / 503:与通用 SP-API 语义一致。
---
脚本参数映射(scripts/patch_listings_item.py)
| 脚本 JSON 字段 | 映射 |
|---|---|
| sellerId / region / sku | storeTokens + path 编码 |
| marketplaceIds | 数组或逗号字符串 → marketplaceIds(Query,逗号拼接) |
| productType / patches | → PATCH body |
| includedData / mode / issueLocale | → Query |
成功且 errcode==200、httpStatus==200 时,stdout 额外含 `patchResult`(解析后的 body)。
---
putListingsItem — 上游语义与网关映射
官方:putListingsItem
创建新刊登或对现有刊登做全量更新(非部分 patch);请求体为完整 `attributes` 对象,须符合该 productType 的 JSON Schema。
HTTP 与方法
- Method:
PUT(经developerProxy.method传入 `PUT`) - Path(与 getListingsItem / patchListingsItem 相同):
listings/2021-08-01/items/{sellerId}/{sku}两段路径均需 URL 编码。
Query 参数(queryString)
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| marketplaceIds | string | 是 | 官方约束:length ≤ 1(仅 一个 marketplace id)。脚本在传入多个 id 时 报错(与 patch 允许多 id 不同) |
| includedData | string | 否 | 逗号分隔;默认文档为 `issues`。可选:identifiers、issues。`identifiers` 仅在 `mode=VALIDATION_PREVIEW` 时可请求 |
| mode | string | 否 | `VALIDATION_PREVIEW`(校验预览) |
| issueLocale | string | 否 | issues 本地化,如 en_US |
Body(developerProxy.body,JSON 字符串)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| productType | string | 是 | Amazon product type(与目录一致,参见 Product Type Definitions) |
| requirements | string | 是 | `LISTING`(商品事实 + 销售条款)\ |
| attributes | object | 是 | 以属性名为键的结构化刊登数据;须满足该 product type 的 schema |
速率(文档摘要)
- 默认约 5 req/s,burst 10(以响应头与账号配额为准)。
响应(httpStatus)
- 200:已理解创建/全量更新请求;是否被接受须解析 `body` 内 issues 等(以 Amazon 响应为准)。
- 400 / 403 / 413 / 415 / 429 / 500 / 503:与通用 SP-API 语义一致。
---
脚本参数映射(scripts/put_listings_item.py)
| 脚本 JSON 字段 | 映射 |
|---|---|
| sellerId / region / sku | storeTokens + path 编码 |
| marketplaceIds | 数组或逗号字符串 → 恰好 1 个 id 写入 Query(多于 1 个则脚本拒绝) |
| productType / requirements / attributes | → PUT body |
| includedData / mode / issueLocale | → Query |
成功且 errcode==200、httpStatus==200 时,stdout 额外含 `putResult`(解析后的 body)。
---
deleteListingsItem — 上游语义与网关映射
官方:deleteListingsItem
删除指定 marketplace 下该 sellerId + sku 的刊登项;无请求体。
HTTP 与方法
- Method:
DELETE(经developerProxy.method传入 `DELETE`) - Path(与 get / patch / put 相同):
listings/2021-08-01/items/{sellerId}/{sku}两段路径均需 URL 编码。
Query 参数(queryString)
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| marketplaceIds | string | 是 | 官方约束:length ≤ 1(仅 一个 marketplace id) |
| issueLocale | string | 否 | 本地化,如 en_US、fr_CA |
速率(文档摘要)
- 默认约 5 req/s,burst 5(以响应头与账号配额为准)。
响应(httpStatus)
- 200:已理解删除请求;是否被接受须解析 `body`(以 Amazon 响应为准)。
- 400 / 403 / 413 / 415 / 429 / 500 / 503:与通用 SP-API 语义一致。
---
脚本参数映射(scripts/delete_listings_item.py)
| 脚本 JSON 字段 | 映射 |
|---|---|
| sellerId / region / sku | storeTokens + path 编码 |
| marketplaceIds | 数组或逗号字符串 → 恰好 1 个 id 写入 Query(多于 1 个则脚本拒绝) |
| issueLocale | 否 → Query |
成功且 errcode==200、httpStatus==200 时,stdout 额外含 `deleteResult`(解析后的 body)。
---
getListingsRestrictions — 上游语义与网关映射
官方:getListingsRestrictions
返回目录 ASIN 在指定 sellerId 与 marketplaceIds 下的刊登限制。
HTTP 与方法
- Method:
GET - Path(固定,无 path 参数):
listings/2021-08-01/restrictionsQuery 参数(queryString)
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| asin | string | 是 | 目录 ASIN |
| sellerId | string | 是 | 卖家标识(与 storeTokens 所用可一致) |
| marketplaceIds | string | 是 | 逗号分隔 marketplace id 列表(脚本接受数组并拼接) |
| conditionType | string | 否 | 商品状况枚举,用于过滤限制(如 new、used_like_new 等,以官方枚举为准) |
| reasonLocale | string | 否 | 原因文案本地化,如 en_US |
速率(文档摘要)
- 默认约 5 req/s,burst 10。
响应
- 200:成功返回限制信息;404 等资源类错误以 Amazon 为准。
---
脚本参数映射(scripts/get_listings_restrictions.py)
| 脚本 JSON 字段 | 映射 |
|---|---|
| sellerId / region | storeTokens |
| asin / marketplaceIds / conditionType / reasonLocale | → Query |
成功且 errcode==200、httpStatus==200 时,stdout 额外含 `restrictionsResult`。
---
searchDefinitionsProductTypes — 上游语义与网关映射
官方:searchDefinitionsProductTypes
搜索可用的 product type 定义列表。
HTTP 与方法
- Method:
GET - Path:
definitions/2020-09-01/productTypesQuery 参数
| 参数名 | 必填 | 说明 |
|---|---|---|
| marketplaceIds | 是 | 逗号分隔(脚本接受数组) |
| keywords | 否 | 逗号分隔关键词列表;不可与 itemName 同用 |
| itemName | 否 | ASIN 商品标题用于推荐 product type;不可与 keywords 同用 |
| locale / searchLocale | 否 | 展示语言、搜索所用 locale |
速率(文档摘要)
- 默认约 5 req/s,burst 10。
---
脚本参数映射(scripts/search_definitions_product_types.py)
| 脚本 JSON 字段 | 映射 |
|---|---|
| sellerId / region | storeTokens |
| marketplaceIds | → Query |
| keywords / itemName | → Query(互斥,脚本校验) |
| locale / searchLocale | → Query |
成功且 errcode==200、httpStatus==200 时,stdout 额外含 `productTypesSearchResult`。
---
getDefinitionsProductType — 上游语义与网关映射
官方:getDefinitionsProductType
按 productType 名称拉取 JSON Schema 定义。
HTTP 与方法
- Method:
GET - Path:
definitions/2020-09-01/productTypes/{productType}{productType} 须 URL 编码(脚本使用 urllib.parse.quote)。
Query 参数
| 参数名 | 必填 | 说明 |
|---|---|---|
| marketplaceIds | 是 | 官方说明当前 limited to one marketplaceId;脚本 多于 1 个即报错 |
| sellerId | 否 | 传入时填充卖家相关 schema;脚本字段 `querySellerId` → Query `sellerId`(避免与用于取令牌的 sellerId 混淆;通常可设为相同值) |
| productTypeVersion | 否 | 默认 `LATEST`;预发布可用 `RELEASE_CANDIDATE`(以官方为准) |
| requirements | 否 | LISTING \ |
| requirementsEnforced | 否 | ENFORCED \ |
| locale | 否 | 展示标签等所用 locale(官方枚举见文档) |
速率(文档摘要)
- 默认约 5 req/s,burst 10。大 schema 响应体可能较大。
---
脚本参数映射(scripts/get_definitions_product_type.py)
| 脚本 JSON 字段 | 映射 |
|---|---|
| sellerId / region | storeTokens(sellerId 为授权店铺) |
| productType | → path 末段(编码) |
| marketplaceIds | → Query,恰好 1 个 id |
| querySellerId | → Query sellerId(可选) |
| productTypeVersion / requirements / requirementsEnforced / locale | → Query |
成功且 errcode==200、httpStatus==200 时,stdout 额外含 `productTypeDefinitionResult`。
---
curl 示例(两步:取令牌 + 代理 GET / PATCH / PUT / DELETE)
1)取 `accessToken`(与 store-report 相同)
curl -sS -X POST "https://tool-gateway.linkfox.com/spApi/storeTokens" \
-H "Authorization: $LINKFOXAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sellerId":"A1BCDEFGHIJK2","region":"NA"}'2)getListingsItem(path 与 query 按实际 sellerId / sku / marketplace 替换)
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": "listings/2021-08-01/items/A1BCDEFGHIJK2/My-Seller-SKU-001",
"method": "GET",
"amzAccessToken": "Atza|IwEBI...",
"queryString": "marketplaceIds=ATVPDKIKX0DER&includedData=summaries,attributes"
}'若sku或sellerId含需编码字符,请使用与脚本一致的 百分号编码 后再放入path。
3)searchListingsItems(path 仅到 `sellerId`,query 含 marketplaceIds 与分页等)
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": "listings/2021-08-01/items/A1BCDEFGHIJK2",
"method": "GET",
"amzAccessToken": "Atza|IwEBI...",
"queryString": "marketplaceIds=ATVPDKIKX0DER&pageSize=10&sortBy=lastUpdatedDate&sortOrder=DESC&includedData=summaries"
}'按 ASIN 搜索时,在 queryString 中增加 identifiers 与 identifiersType(勿与 variationParentSku / packageHierarchySku 同用),例如:identifiers=B08N5WRWNW&identifiersType=ASIN(示例 ASIN 请替换为真实值)。
4)patchListingsItem(`method` 为 `PATCH`,`body` 为 JSON 字符串;`productType` 与 `patches` 须按官方 schema 填写)
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": "listings/2021-08-01/items/A1BCDEFGHIJK2/My-Seller-SKU-001",
"method": "PATCH",
"amzAccessToken": "Atza|IwEBI...",
"queryString": "marketplaceIds=ATVPDKIKX0DER&includedData=issues",
"contentType": "application/json",
"body": "{\"productType\":\"LUGGAGE\",\"patches\":[{\"op\":\"replace\",\"path\":\"/attributes/item_name\",\"value\":[{\"value\":\"Example\",\"marketplace_id\":\"ATVPDKIKX0DER\"}]}]}"
}'上例 `body` 仅演示字段形态;真实path/value必须与 product type JSON Schema 一致,否则会 400。
5)putListingsItem(`method` 为 `PUT`;`marketplaceIds` 在 Query 中仅 一个 id;`body` 含 `productType`、`requirements`、`attributes`)
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": "listings/2021-08-01/items/A1BCDEFGHIJK2/My-Seller-SKU-001",
"method": "PUT",
"amzAccessToken": "Atza|IwEBI...",
"queryString": "marketplaceIds=ATVPDKIKX0DER&includedData=issues",
"contentType": "application/json",
"body": "{\"productType\":\"LUGGAGE\",\"requirements\":\"LISTING\",\"attributes\":{\"item_name\":[{\"value\":\"Example Title\",\"marketplace_id\":\"ATVPDKIKX0DER\"}]}}"
}'上例 `attributes` 仅演示键形态;须与 product type schema 一致。LISTING_OFFER_ONLY 对 Vendor 会 400。
6)deleteListingsItem(`method` 为 `DELETE`;`queryString` 仅含 一个 `marketplaceIds`;不传 `body`)
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": "listings/2021-08-01/items/A1BCDEFGHIJK2/My-Seller-SKU-001",
"method": "DELETE",
"amzAccessToken": "Atza|IwEBI...",
"queryString": "marketplaceIds=ATVPDKIKX0DER"
}'7)getListingsRestrictions(固定 path `listings/2021-08-01/restrictions`,Query 含 asin、sellerId、marketplaceIds)
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": "listings/2021-08-01/restrictions",
"method": "GET",
"amzAccessToken": "Atza|IwEBI...",
"queryString": "asin=B08N5WRWNW&sellerId=A1BCDEFGHIJK2&marketplaceIds=ATVPDKIKX0DER"
}'8)searchDefinitionsProductTypes(path `definitions/2020-09-01/productTypes`;`keywords` 与 `itemName` 勿同用)
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": "definitions/2020-09-01/productTypes",
"method": "GET",
"amzAccessToken": "Atza|IwEBI...",
"queryString": "marketplaceIds=ATVPDKIKX0DER&keywords=luggage,suitcase"
}'9)getDefinitionsProductType(path 末段为 product type 名称;`marketplaceIds` 仅一个)
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": "definitions/2020-09-01/productTypes/LUGGAGE",
"method": "GET",
"amzAccessToken": "Atza|IwEBI...",
"queryString": "marketplaceIds=ATVPDKIKX0DER&requirements=LISTING&productTypeVersion=LATEST"
}'---
Feedback API
- POST
https://skill-api.linkfox.com/api/v1/public/feedback - Content-Type:
application/json
{
"skillName": "linkfox-amazon-store-listings",
"sentiment": "POSITIVE",
"category": "OTHER",
"content": "getListingsItem / searchListingsItems / patchListingsItem / putListingsItem / deleteListingsItem / getListingsRestrictions / searchDefinitionsProductTypes / getDefinitionsProductType 结果符合预期。"
}#!/usr/bin/env python3
"""
Dependency Check - linkfox-amazon-store-listings
==================================================
本脚本用于判断当前运行环境里是否已经安装 / 加载了依赖 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 的脚本是否可执行——
这一点由 get_report.py 在运行时再做一次二次校验(通过尝试调用
/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] = []
# 1) 通过环境变量显式指定(最高优先级)
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())
# 1b) Hermes config.yaml skills.external_dirs 等价注入(本脚本不读 YAML)
# 例: export HERMES_SKILLS_EXTERNAL_DIRS="$HOME/.agents/skills"
roots.extend(_split_path_list(os.environ.get("HERMES_SKILLS_EXTERNAL_DIRS")))
# 2) OpenClaw:工作区下的 skills(与 linkfoxskill / OpenClaw 文档一致)
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")
# 2b) OpenClaw 显式 skills 目录
oc_skills = os.environ.get("OPENCLAW_SKILLS_DIR")
if oc_skills:
roots.append(Path(oc_skills).expanduser())
# 2c) 当前工作目录下的 workspace skills(CLI 常在项目根执行)
try:
cwd = Path.cwd()
roots.append(cwd / "skills")
roots.append(cwd / ".agents" / "skills")
except OSError:
pass
# 3) 与本脚本相邻的 skills/ 目录(仓库开发场景)
here = Path(__file__).resolve()
# .../skills/linkfox-amazon-store-report/scripts/check_auth_dependency.py
# parents[2] -> .../skills
if len(here.parents) >= 3:
roots.append(here.parents[2])
# 4) 用户级常见安装位置(Claude / Cursor / LinkFox)
home = Path.home()
roots.extend([
home / ".claude" / "skills",
home / ".cursor" / "skills",
home / ".cursor" / "skills-cursor",
home / ".linkfox" / "skills",
])
# 5) OpenClaw 全局与跨工具共享目录
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:
"""
Hermes Agent 布局: ~/.hermes/skills/<category>/<skill-name>/SKILL.md
跳过 .hub、点目录等非 category 项。
"""
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:
"""~/.hermes/plugins/<plugin>/skills/<skill-name>/SKILL.md"""
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:
"""返回依赖 skill 的 SKILL.md 路径;未找到返回 None。"""
home = Path.home()
for skill_dir_name in _AUTH_SKILL_DIR_ALIASES:
# A) 扁平布局:root/<skill_dir_name>/SKILL.md
for root in candidate_skill_roots():
target = root / skill_dir_name / "SKILL.md"
if target.is_file():
return target
# B) Hermes:~/.hermes/skills/<category>/<skill_dir_name>/SKILL.md
hermes_default = home / ".hermes" / "skills"
found = _hermes_category_skill_md(hermes_default, skill_dir_name)
if found is not None:
return found
# C) Hermes:显式 HERMES_SKILLS_HOME(若用户把 category 根指到别处)
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
# D) Hermes 插件内 skills
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]:
"""供 DEPENDENCY_MISSING 调试:列出已扫描的扁平根目录 + Hermes 特化路径。"""
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:
# 把 prefix 也放进 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)
# 未找到:给 agent 一份结构化的行动指引
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": (
f"linkfox-amazon-store-listings 依赖 `{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 — Delete Listings Item (SP-API Listings Items v2021-08-01)
=======================================================================
通过 LinkFox **POST /spApi/developerProxy** 转发 **DELETE deleteListingsItem**,
删除指定 marketplace 下的刊登;与 get 相同:先 **storeTokens**,再代理调用(**无**请求体)。
官方参考: https://developer-docs.amazon.com/sp-api/reference/deletelistingsitem
Usage:
python delete_listings_item.py '{
"sellerId": "A1BCDEFGHIJK2",
"region": "NA",
"sku": "My-Seller-SKU-001",
"marketplaceIds": ["ATVPDKIKX0DER"]
}'
Optional JSON:
issueLocale, skipDepCheck
说明:官方 **marketplaceIds** 约束为 **length ≤ 1**;脚本在多于 1 个 id 时报错。
"""
from typing import List
import json
import os
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
LISTINGS_API_VERSION = "2021-08-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"
REQUIRED_SKILL = "linkfox-amazon-store-auth"
DEPENDENCY_EXIT_CODE = 42
def ensure_auth_skill_available() -> None:
here = Path(__file__).resolve().parent
checker = here / "check_auth_dependency.py"
if not checker.exists():
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": "check_auth_dependency.py not found next to delete_listings_item.py",
}
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) -> 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=60) 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_delete(
region: str,
path: str,
access_token: str,
query_string: str,
) -> dict:
params: dict = {
"region": region,
"path": path,
"method": "DELETE",
"amzAccessToken": access_token,
"queryString": query_string,
}
return call_api(DEVELOPER_PROXY_ENDPOINT, params)
def _path_delete(seller_id: str, sku: str) -> str:
return (
f"listings/{LISTINGS_API_VERSION}/items/"
f"{quote(seller_id, safe='')}/{quote(sku, safe='')}"
)
def _norm_marketplace_ids(val: object) -> 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("marketplaceIds must be a string or non-empty array")
def _build_delete_query(params: dict) -> str:
mids = _norm_marketplace_ids(params["marketplaceIds"])
if not mids:
raise ValueError("marketplaceIds is required")
if len(mids) > 1:
raise ValueError(
"deleteListingsItem requires at most one marketplace id (Amazon: marketplaceIds length ≤ 1)"
)
parts: List[str] = [f"marketplaceIds={quote(mids[0], safe='')}"]
il = params.get("issueLocale")
if il is not None and str(il).strip():
parts.append(f"issueLocale={quote(str(il).strip(), safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: delete_listings_item.py '<JSON>'\n"
"Required: sellerId, region, sku, marketplaceIds (exactly one id)\n"
"Optional: issueLocale\n"
"See https://developer-docs.amazon.com/sp-api/reference/deletelistingsitem",
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()
for f in ("sellerId", "region", "sku", "marketplaceIds"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
seller_id = str(params["sellerId"])
region = str(params["region"])
sku = str(params["sku"])
try:
query_string = _build_delete_query(params)
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
path = _path_delete(seller_id, sku)
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)
access_token = tokens["accessToken"]
proxy = developer_proxy_delete(region, path, access_token, query_string)
out = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
body_raw = proxy.get("body") or "{}"
try:
out["deleteResult"] = json.loads(body_raw)
except json.JSONDecodeError:
out["deleteResult"] = None
out["deleteResultRaw"] = body_raw
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — Get Definitions Product Type (SP-API Product Type Definitions v2020-09-01)
==========================================================================================
通过 LinkFox **POST /spApi/developerProxy** 转发 **GET getDefinitionsProductType**,
获取指定 Amazon product type 的 JSON Schema 定义。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getdefinitionsproducttype
Usage:
python get_definitions_product_type.py '{
"sellerId": "A1BCDEFGHIJK2",
"region": "NA",
"productType": "LUGGAGE",
"marketplaceIds": ["ATVPDKIKX0DER"]
}'
Optional JSON:
querySellerId → 写入 Query 的 sellerId(卖家专属 schema;可选,常与取令牌用的 sellerId 相同)
productTypeVersion, requirements, requirementsEnforced, locale, skipDepCheck
"""
from typing import List
import json
import os
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFINITIONS_API_VERSION = "2020-09-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"
REQUIRED_SKILL = "linkfox-amazon-store-auth"
DEPENDENCY_EXIT_CODE = 42
def ensure_auth_skill_available() -> None:
here = Path(__file__).resolve().parent
checker = here / "check_auth_dependency.py"
if not checker.exists():
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": "check_auth_dependency.py not found next to get_definitions_product_type.py",
}
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) -> 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=120) 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: str,
) -> dict:
params: dict = {
"region": region,
"path": path,
"method": "GET",
"amzAccessToken": access_token,
"queryString": query_string,
}
return call_api(DEVELOPER_PROXY_ENDPOINT, params)
def _norm_marketplace_ids(val: object) -> 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("marketplaceIds must be a string or non-empty array")
def _path_for_product_type(product_type: str) -> str:
enc = quote(product_type.strip(), safe="")
return f"definitions/{DEFINITIONS_API_VERSION}/productTypes/{enc}"
def _build_query(params: dict) -> str:
mids = _norm_marketplace_ids(params["marketplaceIds"])
if not mids:
raise ValueError("marketplaceIds is required")
if len(mids) > 1:
raise ValueError(
"getDefinitionsProductType: marketplaceIds is limited to one id at this time (Amazon)"
)
parts: List[str] = [f"marketplaceIds={quote(mids[0], safe='')}"]
qs = params.get("querySellerId")
if qs is not None and str(qs).strip():
parts.append(f"sellerId={quote(str(qs).strip(), safe='')}")
ptv = params.get("productTypeVersion")
if ptv is not None and str(ptv).strip():
parts.append(f"productTypeVersion={quote(str(ptv).strip(), safe='')}")
req = params.get("requirements")
if req is not None and str(req).strip():
parts.append(f"requirements={quote(str(req).strip(), safe='')}")
re_ = params.get("requirementsEnforced")
if re_ is not None and str(re_).strip():
parts.append(f"requirementsEnforced={quote(str(re_).strip(), safe='')}")
loc = params.get("locale")
if loc is not None and str(loc).strip():
parts.append(f"locale={quote(str(loc).strip(), safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: get_definitions_product_type.py '<JSON>'\n"
"Required: sellerId (用于 storeTokens), region, productType, marketplaceIds (exactly one id)\n"
"Optional: querySellerId → Query sellerId; productTypeVersion; requirements; "
"requirementsEnforced; locale\n"
"See https://developer-docs.amazon.com/sp-api/reference/getdefinitionsproducttype",
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()
for f in ("sellerId", "region", "productType", "marketplaceIds"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
seller_id = str(params["sellerId"])
region = str(params["region"])
product_type = str(params["productType"]).strip()
if not product_type:
print("productType must be non-empty", file=sys.stderr)
sys.exit(1)
try:
query_string = _build_query(params)
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
path = _path_for_product_type(product_type)
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 = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
body_raw = proxy.get("body") or "{}"
try:
out["productTypeDefinitionResult"] = json.loads(body_raw)
except json.JSONDecodeError:
out["productTypeDefinitionResult"] = None
out["productTypeDefinitionResultRaw"] = body_raw
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — Get Listings Item (SP-API Listings Items v2021-08-01)
====================================================================
通过 LinkFox 店铺网关 **POST /spApi/developerProxy** 转发 **GET getListingsItem**,
与 `linkfox-amazon-store-report` 使用同一套通用代理接口。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getlistingsitem
Usage:
python get_listings_item.py '{
"sellerId": "A1BCDEFGHIJK2",
"region": "NA",
"sku": "My-Seller-SKU-001",
"marketplaceIds": ["ATVPDKIKX0DER"]
}'
Optional JSON fields:
- includedData: string[] 或逗号分隔字符串;默认不传则上游通常为 summaries
- issueLocale: 例如 en_US(可选)
- skipDepCheck: boolean,跳过依赖检查(默认 false)
Dependency:
`linkfox-amazon-store-auth`(与 store-report 相同;启动时运行 check_auth_dependency.py)
"""
from typing import List, Optional
import json
import os
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
LISTINGS_API_VERSION = "2021-08-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"
REQUIRED_SKILL = "linkfox-amazon-store-auth"
DEPENDENCY_EXIT_CODE = 42
def ensure_auth_skill_available() -> None:
here = Path(__file__).resolve().parent
checker = here / "check_auth_dependency.py"
if not checker.exists():
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": "check_auth_dependency.py not found next to get_listings_item.py",
}
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) -> 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=60) 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,
) -> 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)
def _path_for_get_listings_item(seller_id: str, sku: str) -> str:
# Path segments must be percent-encoded (SKU 可含空格、斜杠等)
enc_seller = quote(seller_id, safe="")
enc_sku = quote(sku, safe="")
return f"listings/{LISTINGS_API_VERSION}/items/{enc_seller}/{enc_sku}"
def _build_query_string(
marketplace_ids: List[str],
included_data: Optional[object],
issue_locale: Optional[str],
) -> str:
parts: list[str] = []
# 官方要求 marketplaceIds;文档常见为单站点(length ≤ 1)。多传时仅取第一个并告警。
if not marketplace_ids:
raise ValueError("marketplaceIds must be a non-empty array of strings")
mid = marketplace_ids[0]
parts.append(f"marketplaceIds={quote(mid, safe='')}")
if len(marketplace_ids) > 1:
print(
"⚠️ Warning: getListingsItem marketplaceIds length is typically 1; using first only.",
file=sys.stderr,
)
if included_data is not None:
if isinstance(included_data, list):
id_val = ",".join(included_data)
else:
id_val = str(included_data).strip()
if id_val:
parts.append(f"includedData={quote(id_val, safe='')}")
if issue_locale:
parts.append(f"issueLocale={quote(issue_locale, safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: get_listings_item.py '<JSON>'\n"
"Required: sellerId, region, sku, marketplaceIds (array with ≥1 id)\n"
"Example: get_listings_item.py "
'\'{"sellerId":"A1...","region":"NA","sku":"ABC-1","marketplaceIds":["ATVPDKIKX0DER"]}\'',
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()
for f in ("sellerId", "region", "sku", "marketplaceIds"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
seller_id = str(params["sellerId"])
region = str(params["region"])
sku = str(params["sku"])
mids = params["marketplaceIds"]
if isinstance(mids, str):
marketplace_ids = [mids]
elif isinstance(mids, list):
marketplace_ids = [str(x) for x in mids]
else:
print("marketplaceIds must be a string or array of strings", file=sys.stderr)
sys.exit(1)
included = params.get("includedData")
issue_locale = params.get("issueLocale")
if issue_locale is not None:
issue_locale = str(issue_locale)
try:
query_string = _build_query_string(marketplace_ids, included, issue_locale)
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
path = _path_for_get_listings_item(seller_id, sku)
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)
access_token = tokens["accessToken"]
proxy = developer_proxy_get(region, path, access_token, query_string)
out = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
body_raw = proxy.get("body") or "{}"
try:
out["listing"] = json.loads(body_raw)
except json.JSONDecodeError:
out["listing"] = None
out["listingRaw"] = body_raw
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — Get Listings Restrictions (SP-API Listings Restrictions v2021-08-01)
==================================================================================
通过 LinkFox **POST /spApi/developerProxy** 转发 **GET getListingsRestrictions**,
查询目录 ASIN 在指定卖家与市场下的刊登限制。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getlistingsrestrictions
Usage:
python get_listings_restrictions.py '{
"sellerId": "A1BCDEFGHIJK2",
"region": "NA",
"asin": "B08N5WRWNW",
"marketplaceIds": ["ATVPDKIKX0DER"]
}'
Optional JSON:
conditionType (enum, 如 new / used_like_new 等), reasonLocale, skipDepCheck
"""
from typing import List
import json
import os
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
LISTINGS_API_VERSION = "2021-08-01"
RESTRICTIONS_PATH = f"listings/{LISTINGS_API_VERSION}/restrictions"
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"
REQUIRED_SKILL = "linkfox-amazon-store-auth"
DEPENDENCY_EXIT_CODE = 42
def ensure_auth_skill_available() -> None:
here = Path(__file__).resolve().parent
checker = here / "check_auth_dependency.py"
if not checker.exists():
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": "check_auth_dependency.py not found next to get_listings_restrictions.py",
}
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) -> 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=60) 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: str,
) -> dict:
params: dict = {
"region": region,
"path": path,
"method": "GET",
"amzAccessToken": access_token,
"queryString": query_string,
}
return call_api(DEVELOPER_PROXY_ENDPOINT, params)
def _norm_marketplace_ids(val: object) -> 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("marketplaceIds must be a string or non-empty array")
def _build_query(params: dict, seller_id: str, asin: str) -> str:
mids = _norm_marketplace_ids(params["marketplaceIds"])
if not mids:
raise ValueError("marketplaceIds is required")
parts: List[str] = [
f"asin={quote(asin, safe='')}",
f"sellerId={quote(seller_id, safe='')}",
f"marketplaceIds={quote(','.join(mids), safe='')}",
]
ct = params.get("conditionType")
if ct is not None and str(ct).strip():
parts.append(f"conditionType={quote(str(ct).strip(), safe='')}")
rl = params.get("reasonLocale")
if rl is not None and str(rl).strip():
parts.append(f"reasonLocale={quote(str(rl).strip(), safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: get_listings_restrictions.py '<JSON>'\n"
"Required: sellerId, region, asin, marketplaceIds\n"
"Optional: conditionType, reasonLocale\n"
"See https://developer-docs.amazon.com/sp-api/reference/getlistingsrestrictions",
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()
for f in ("sellerId", "region", "asin", "marketplaceIds"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
seller_id = str(params["sellerId"])
region = str(params["region"])
asin = str(params["asin"]).strip()
if not asin:
print("asin must be non-empty", file=sys.stderr)
sys.exit(1)
try:
query_string = _build_query(params, seller_id, asin)
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
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, RESTRICTIONS_PATH, tokens["accessToken"], query_string)
out = {
"developerProxy": proxy,
"resolvedPath": RESTRICTIONS_PATH,
"queryString": query_string,
}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
body_raw = proxy.get("body") or "{}"
try:
out["restrictionsResult"] = json.loads(body_raw)
except json.JSONDecodeError:
out["restrictionsResult"] = None
out["restrictionsResultRaw"] = body_raw
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — Patch Listings Item (SP-API Listings Items v2021-08-01)
======================================================================
通过 LinkFox **POST /spApi/developerProxy** 转发 **PATCH patchListingsItem**,
与 get/search 相同:先 **storeTokens**,再带 **body**(JSON 字符串)调用代理。
官方参考: https://developer-docs.amazon.com/sp-api/reference/patchlistingsitem
Usage:
python patch_listings_item.py '{
"sellerId": "A1BCDEFGHIJK2",
"region": "NA",
"sku": "My-Seller-SKU-001",
"marketplaceIds": ["ATVPDKIKX0DER"],
"productType": "PRODUCT",
"patches": [
{"op": "replace", "path": "/attributes/item_name", "value": [{"value": "New Title", "marketplace_id": "ATVPDKIKX0DER"}]}
]
}'
说明:path / value 须符合 Amazon 对该 productType 的 JSON Patch 与属性 schema;上例仅演示结构,请按实网/官方 schema 填写。
Optional JSON:
includedData, mode (VALIDATION_PREVIEW), issueLocale, skipDepCheck
"""
from typing import Any, Dict, List, Optional
import json
import os
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
LISTINGS_API_VERSION = "2021-08-01"
PATCH_OPS = frozenset({"add", "replace", "merge", "delete"})
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"
REQUIRED_SKILL = "linkfox-amazon-store-auth"
DEPENDENCY_EXIT_CODE = 42
def ensure_auth_skill_available() -> None:
here = Path(__file__).resolve().parent
checker = here / "check_auth_dependency.py"
if not checker.exists():
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": "check_auth_dependency.py not found next to patch_listings_item.py",
}
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) -> 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=60) 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_patch(
region: str,
path: str,
access_token: str,
query_string: str,
body_obj: Dict[str, Any],
) -> dict:
params: dict = {
"region": region,
"path": path,
"method": "PATCH",
"amzAccessToken": access_token,
"queryString": query_string,
"body": json.dumps(body_obj, ensure_ascii=False),
"contentType": "application/json",
}
return call_api(DEVELOPER_PROXY_ENDPOINT, params)
def _path_patch(seller_id: str, sku: str) -> str:
return (
f"listings/{LISTINGS_API_VERSION}/items/"
f"{quote(seller_id, safe='')}/{quote(sku, safe='')}"
)
def _norm_marketplace_ids(val: object) -> 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("marketplaceIds must be a string or non-empty array")
def _build_patch_query(params: dict) -> str:
mids = _norm_marketplace_ids(params["marketplaceIds"])
if not mids:
raise ValueError("marketplaceIds is required")
parts: List[str] = [f"marketplaceIds={quote(','.join(mids), safe='')}"]
inc = params.get("includedData")
if inc is not None:
if isinstance(inc, list):
iv = ",".join(str(x) for x in inc)
else:
iv = str(inc).strip()
if iv:
parts.append(f"includedData={quote(iv, safe='')}")
mode = params.get("mode")
if mode is not None and str(mode).strip():
parts.append(f"mode={quote(str(mode).strip(), safe='')}")
il = params.get("issueLocale")
if il is not None and str(il).strip():
parts.append(f"issueLocale={quote(str(il).strip(), safe='')}")
return "&".join(parts)
def _validate_patches(patches: object) -> List[Dict[str, Any]]:
if not isinstance(patches, list) or len(patches) < 1:
raise ValueError("patches must be a non-empty array (Amazon: length >= 1)")
out: List[Dict[str, Any]] = []
for i, p in enumerate(patches):
if not isinstance(p, dict):
raise ValueError(f"patches[{i}] must be an object")
op = p.get("op")
path_v = p.get("path")
if not op or not path_v:
raise ValueError(f"patches[{i}] requires 'op' and 'path'")
if str(op) not in PATCH_OPS:
raise ValueError(
f"patches[{i}].op must be one of {sorted(PATCH_OPS)} (RFC 6902 subset per Amazon)"
)
out.append(p)
return out
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: patch_listings_item.py '<JSON>'\n"
"Required: sellerId, region, sku, marketplaceIds, productType, patches (array)\n"
"Optional: includedData, mode (e.g. VALIDATION_PREVIEW), issueLocale\n"
"See https://developer-docs.amazon.com/sp-api/reference/patchlistingsitem",
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()
for f in ("sellerId", "region", "sku", "marketplaceIds", "productType", "patches"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
seller_id = str(params["sellerId"])
region = str(params["region"])
sku = str(params["sku"])
product_type = str(params["productType"])
try:
patches = _validate_patches(params["patches"])
query_string = _build_patch_query(params)
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
body_obj: Dict[str, Any] = {"productType": product_type, "patches": patches}
path = _path_patch(seller_id, sku)
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)
access_token = tokens["accessToken"]
proxy = developer_proxy_patch(region, path, access_token, query_string, body_obj)
out: Dict[str, Any] = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
"requestBody": body_obj,
}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
body_raw = proxy.get("body") or "{}"
try:
out["patchResult"] = json.loads(body_raw)
except json.JSONDecodeError:
out["patchResult"] = None
out["patchResultRaw"] = body_raw
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — Put Listings Item (SP-API Listings Items v2021-08-01)
===================================================================
通过 LinkFox **POST /spApi/developerProxy** 转发 **PUT putListingsItem**,
创建新刊登或对现有刊登做**全量**更新;与 get/patch 相同:先 **storeTokens**,再带 **body**(JSON 字符串)调用代理。
官方参考: https://developer-docs.amazon.com/sp-api/reference/putlistingsitem
Usage:
python put_listings_item.py '{
"sellerId": "A1BCDEFGHIJK2",
"region": "NA",
"sku": "My-Seller-SKU-001",
"marketplaceIds": ["ATVPDKIKX0DER"],
"productType": "LUGGAGE",
"requirements": "LISTING",
"attributes": { "item_name": [ { "value": "Title", "marketplace_id": "ATVPDKIKX0DER" } ] }
}'
说明:`attributes` 须符合该 productType 的 JSON Schema;上例仅演示结构。
Optional JSON:
includedData, mode (VALIDATION_PREVIEW), issueLocale, skipDepCheck
"""
from typing import Any, Dict, List
import json
import os
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
LISTINGS_API_VERSION = "2021-08-01"
REQUIREMENTS_SET = frozenset({"LISTING", "LISTING_PRODUCT_ONLY", "LISTING_OFFER_ONLY"})
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"
REQUIRED_SKILL = "linkfox-amazon-store-auth"
DEPENDENCY_EXIT_CODE = 42
def ensure_auth_skill_available() -> None:
here = Path(__file__).resolve().parent
checker = here / "check_auth_dependency.py"
if not checker.exists():
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": "check_auth_dependency.py not found next to put_listings_item.py",
}
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) -> 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=120) 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_put(
region: str,
path: str,
access_token: str,
query_string: str,
body_obj: Dict[str, Any],
) -> dict:
params: dict = {
"region": region,
"path": path,
"method": "PUT",
"amzAccessToken": access_token,
"queryString": query_string,
"body": json.dumps(body_obj, ensure_ascii=False),
"contentType": "application/json",
}
return call_api(DEVELOPER_PROXY_ENDPOINT, params)
def _path_put(seller_id: str, sku: str) -> str:
return (
f"listings/{LISTINGS_API_VERSION}/items/"
f"{quote(seller_id, safe='')}/{quote(sku, safe='')}"
)
def _norm_marketplace_ids(val: object) -> 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("marketplaceIds must be a string or non-empty array")
def _build_put_query(params: dict) -> str:
mids = _norm_marketplace_ids(params["marketplaceIds"])
if not mids:
raise ValueError("marketplaceIds is required")
if len(mids) > 1:
raise ValueError(
"putListingsItem requires at most one marketplace id (Amazon: marketplaceIds length ≤ 1)"
)
parts: List[str] = [f"marketplaceIds={quote(mids[0], safe='')}"]
inc = params.get("includedData")
if inc is not None:
if isinstance(inc, list):
iv = ",".join(str(x) for x in inc)
else:
iv = str(inc).strip()
if iv:
parts.append(f"includedData={quote(iv, safe='')}")
mode = params.get("mode")
if mode is not None and str(mode).strip():
parts.append(f"mode={quote(str(mode).strip(), safe='')}")
il = params.get("issueLocale")
if il is not None and str(il).strip():
parts.append(f"issueLocale={quote(str(il).strip(), safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: put_listings_item.py '<JSON>'\n"
"Required: sellerId, region, sku, marketplaceIds (exactly one id), "
"productType, requirements, attributes (object)\n"
"requirements: LISTING | LISTING_PRODUCT_ONLY | LISTING_OFFER_ONLY\n"
"Optional: includedData, mode (e.g. VALIDATION_PREVIEW), issueLocale\n"
"See https://developer-docs.amazon.com/sp-api/reference/putlistingsitem",
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()
for f in ("sellerId", "region", "sku", "marketplaceIds", "productType", "requirements", "attributes"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
seller_id = str(params["sellerId"])
region = str(params["region"])
sku = str(params["sku"])
product_type = str(params["productType"])
requirements = str(params["requirements"]).strip()
if requirements not in REQUIREMENTS_SET:
print(
f"requirements must be one of {sorted(REQUIREMENTS_SET)}",
file=sys.stderr,
)
sys.exit(1)
attrs = params["attributes"]
if not isinstance(attrs, dict):
print("attributes must be a JSON object", file=sys.stderr)
sys.exit(1)
try:
query_string = _build_put_query(params)
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
body_obj: Dict[str, Any] = {
"productType": product_type,
"requirements": requirements,
"attributes": attrs,
}
path = _path_put(seller_id, sku)
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)
access_token = tokens["accessToken"]
proxy = developer_proxy_put(region, path, access_token, query_string, body_obj)
out: Dict[str, Any] = {
"developerProxy": proxy,
"resolvedPath": path,
"queryString": query_string,
"requestBody": body_obj,
}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
body_raw = proxy.get("body") or "{}"
try:
out["putResult"] = json.loads(body_raw)
except json.JSONDecodeError:
out["putResult"] = None
out["putResultRaw"] = body_raw
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 — Search Definitions Product Types (SP-API Product Type Definitions v2020-09-01)
===============================================================================================
通过 LinkFox **POST /spApi/developerProxy** 转发 **GET searchDefinitionsProductTypes**,
按关键词或商品标题搜索可用的 Amazon product type。
官方参考: https://developer-docs.amazon.com/sp-api/reference/searchdefinitionsproducttypes
Usage:
python search_definitions_product_types.py '{
"sellerId": "A1BCDEFGHIJK2",
"region": "NA",
"marketplaceIds": ["ATVPDKIKX0DER"],
"keywords": ["luggage", "suitcase"]
}'
或使用 itemName(与 keywords 互斥):
"itemName": "Carry-On Spinner 20 Inch"
Optional JSON:
locale, searchLocale, skipDepCheck
"""
from typing import List
import json
import os
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFINITIONS_API_VERSION = "2020-09-01"
SEARCH_PATH = f"definitions/{DEFINITIONS_API_VERSION}/productTypes"
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"
REQUIRED_SKILL = "linkfox-amazon-store-auth"
DEPENDENCY_EXIT_CODE = 42
def ensure_auth_skill_available() -> None:
here = Path(__file__).resolve().parent
checker = here / "check_auth_dependency.py"
if not checker.exists():
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": "check_auth_dependency.py not found next to search_definitions_product_types.py",
}
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) -> 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=60) 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: str,
) -> dict:
params: dict = {
"region": region,
"path": path,
"method": "GET",
"amzAccessToken": access_token,
"queryString": query_string,
}
return call_api(DEVELOPER_PROXY_ENDPOINT, params)
def _norm_marketplace_ids(val: object) -> 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("marketplaceIds must be a string or non-empty array")
def _keywords_nonempty(params: dict) -> bool:
kw = params.get("keywords")
if kw is None:
return False
if isinstance(kw, str):
return bool(kw.strip())
if isinstance(kw, list):
return any(str(x).strip() for x in kw)
return False
def _item_name_nonempty(params: dict) -> bool:
n = params.get("itemName")
return n is not None and str(n).strip() != ""
def _build_query(params: dict) -> str:
mids = _norm_marketplace_ids(params["marketplaceIds"])
if not mids:
raise ValueError("marketplaceIds is required")
parts: List[str] = [f"marketplaceIds={quote(','.join(mids), safe='')}"]
has_kw = _keywords_nonempty(params)
has_in = _item_name_nonempty(params)
if has_kw and has_in:
raise ValueError("keywords and itemName cannot be used together (Amazon API)")
kw = params.get("keywords")
if has_kw:
if isinstance(kw, list):
kstr = ",".join(str(x).strip() for x in kw if str(x).strip())
else:
kstr = str(kw).strip()
if kstr:
parts.append(f"keywords={quote(kstr, safe='')}")
if has_in:
parts.append(f"itemName={quote(str(params['itemName']).strip(), safe='')}")
loc = params.get("locale")
if loc is not None and str(loc).strip():
parts.append(f"locale={quote(str(loc).strip(), safe='')}")
sl = params.get("searchLocale")
if sl is not None and str(sl).strip():
parts.append(f"searchLocale={quote(str(sl).strip(), safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: search_definitions_product_types.py '<JSON>'\n"
"Required: sellerId, region, marketplaceIds\n"
"Optional: keywords (array or string, comma-separated) OR itemName — not both\n"
"Optional: locale, searchLocale\n"
"See https://developer-docs.amazon.com/sp-api/reference/searchdefinitionsproducttypes",
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()
for f in ("sellerId", "region", "marketplaceIds"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
seller_id = str(params["sellerId"])
region = str(params["region"])
try:
query_string = _build_query(params)
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
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, SEARCH_PATH, tokens["accessToken"], query_string)
out = {
"developerProxy": proxy,
"resolvedPath": SEARCH_PATH,
"queryString": query_string,
}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
body_raw = proxy.get("body") or "{}"
try:
out["productTypesSearchResult"] = json.loads(body_raw)
except json.JSONDecodeError:
out["productTypesSearchResult"] = None
out["productTypesSearchResultRaw"] = body_raw
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()