
Linkfox Amazon Store Pricing
- 183 installs
- 64 repo stars
- Updated August 3, 2026
- linkfox-ai/linkfox-skills
Helps with ai & agent building tasks.
About
linkfox-amazon-store-pricing is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- linkfox-amazon-store-pricing
- AI & Agent Building
- AI-coding skill
Linkfox Amazon Store Pricing by the numbers
- 183 all-time installs (skills.sh)
- +35 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #3,026 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-pricingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 183 |
|---|---|
| repo stars | ★ 64 |
| Last updated | August 3, 2026 |
| Repository | linkfox-ai/linkfox-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Amazon 店铺 Product Pricing
本 skill 与 `linkfox-amazon-store-auth`、`linkfox-amazon-store-report`、`linkfox-amazon-store-listings` 同属 Amazon Store 系列:先 `POST /spApi/storeTokens` 取 accessToken,再 `POST /spApi/developerProxy` 转发上游 GET 或 POST(与 listings 的 PUT/PATCH 代理方式一致)。
官方参考索引
| 能力 | 文档 |
|---|---|
| getPricing | getPricing |
| getCompetitivePricing | getCompetitivePricing |
| getListingOffers | getListingOffers |
| getItemOffers | getItemOffers |
| getItemOffersBatch | getItemOffersBatch |
| getListingOffersBatch | getListingOffersBatch |
| getFeaturedOfferExpectedPriceBatch | getFeaturedOfferExpectedPriceBatch |
| getCompetitiveSummary | getCompetitiveSummary |
---
Prerequisites(必须先读)
本 skill 依赖 `linkfox-amazon-store-auth`。
1. 运行 python scripts/check_auth_dependency.py;若 exit code 42 且 stderr 含 DEPENDENCY_MISSING:,请先安装 `linkfox-amazon-store-auth`。 2. 不要在本 skill 内绕过依赖实现授权或令牌逻辑。
---
Current Capabilities(脚本一览)
| 能力 | developerProxy path(要点) | 脚本 |
|---|---|---|
| getPricing | products/pricing/v0/price + Query | get_pricing.py |
| getCompetitivePricing | products/pricing/v0/competitivePrice + Query | get_competitive_pricing.py |
| getListingOffers | products/pricing/v0/listings/{sku}/offers + Query | get_listing_offers.py |
| getItemOffers | products/pricing/v0/items/{asin}/offers + Query | get_item_offers.py |
| getItemOffersBatch | batches/products/pricing/v0/itemOffers,POST JSON body | post_item_offers_batch.py |
| getListingOffersBatch | batches/products/pricing/v0/listingOffers,POST JSON body | post_listing_offers_batch.py |
| getFeaturedOfferExpectedPriceBatch | batches/products/pricing/2022-05-01/offer/featuredOfferExpectedPrice,POST | post_featured_offer_expected_price_batch.py |
| getCompetitiveSummary | batches/products/pricing/2022-05-01/items/competitiveSummary,POST | post_competitive_summary_batch.py |
批量脚本(post_*_batch.py)在默认模式下会按 Amazon 要求组装子请求;高级用法可设 `useAmazonRequestShape`: true,直接传 `requests` 为官方原始数组(仍受条数上限约束)。共享逻辑见 `scripts/_spapi_pricing_common.py`(仅供同目录脚本 import,非独立 CLI)。
---
Quick Parameters(摘要)
- getPricing / getCompetitivePricing:
sellerId、region、marketplaceId(或marketplaceIds取首)、itemType、asins或skus(≤20);getPricing 另有itemCondition、offerType;getCompetitivePricing 另有customerType。 - getListingOffers / getItemOffers:
sku+path 或 `asin`+path;`itemCondition` 必填;可选customerType。 - Item / Listing Offers Batch:
requests数组,默认每项为简化对象(见references/api.md);1~20 条(FOEP 批量脚本为 最多 40 条)。 - getCompetitiveSummary 批量:每项需 `asin`、`marketplaceId`、`includedData`(非空字符串数组);可选 `lowestPricedOffersInputs`。
- getFeaturedOfferExpectedPriceBatch:每项需 `marketplaceId`、`sku`、`segment`(对象,结构以官方为准)。
---
Scripts
get_pricing.py·get_competitive_pricing.py·get_listing_offers.py·get_item_offers.pypost_item_offers_batch.py·post_listing_offers_batch.py·post_featured_offer_expected_price_batch.py·post_competitive_summary_batch.pycheck_auth_dependency.py·_spapi_pricing_common.py(内部模块)
export LINKFOXAGENT_API_KEY="<your-key>"
python scripts/get_item_offers.py '{"sellerId":"A1...","region":"NA","asin":"B0...","marketplaceId":"ATVPDKIKX0DER","itemCondition":"New"}'
python scripts/post_item_offers_batch.py '{"sellerId":"A1...","region":"NA","requests":[{"asin":"B0...","marketplaceId":"ATVPDKIKX0DER","itemCondition":"New"}]}'---
Display Rules
1. `MarketplaceId`(单数)与 Listings 的 marketplaceIds 勿混用。 2. 先看网关 `errcode` / `httpStatus`,再解析各脚本对应的解析字段(如 `itemOffers`、`itemOffersBatch`、`competitiveSummary` 等)。 3. POST 类接口:stdout 中含 `requestBody`(脚本组装的 Amazon 请求体),便于排查。 4. 白名单:除 products/pricing/... 外,批量路径以 `batches/products/pricing/...` 开头;1005 时需后端放行对应前缀。 5. 各接口 Usage plan 不同(尤其 2022-05-01 批量约 0.033 req/s),注意 429。
---
Important Limitations
- 权限:Product Pricing 及相关角色;部分 2022-05-01 能力可能另有应用内配置要求,以 Amazon 为准。
- FOEP 批量:
segment须符合官方模型;条数上限脚本按 40 校验(与文档「up to 40」一致)。 - 返回结构以 Amazon schema 为准;详见 `references/api.md`。
Feedback: 见 references/api.md,skillName:linkfox-amazon-store-pricing。
--- 更多跨境 skill:[LinkFox Skills](https://skill.linkfox.com/)
<!-- LF_LARGE_RESPONSE_BLOCK -->
Handling Large Responses
To avoid overflowing the agent context, persist the response to disk and extract only the fields you need:
python scripts/response_io.py run --script scripts/check_auth_dependency.py --out-dir <DIR> '<params>'
python scripts/response_io.py read <file> --fields "<paths>" # or --path "<JMESPath>"Pick--out-diroutside any git working tree (e.g./tmp/...on Unix,%TEMP%/...on Windows). Persisted responses may contain PII, pricing, or auth-sensitive data — do not commit them. Files are not auto-deleted; clean up when the task is done.
This skill exposes multiple entry scripts:check_auth_dependency.py,get_competitive_pricing.py,get_item_offers.py,get_listing_offers.py,get_pricing.py,post_competitive_summary_batch.py,post_featured_offer_expected_price_batch.py,post_item_offers_batch.py,post_listing_offers_batch.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 店铺 Product Pricing API 参考(v0 + 2022-05-01 批量)
本文档描述通过 LinkFox 店铺网关 调用 Selling Partner API Product Pricing(v0 与 2022-05-01 批量):与 linkfox-amazon-store-report、linkfox-amazon-store-listings 一致——先 `POST /spApi/storeTokens` 取 accessToken,再经 `POST /spApi/developerProxy` 转发上游 GET 或 POST。
官方入口:getPricing · getCompetitivePricing · getListingOffers · getItemOffers · getItemOffersBatch · getListingOffersBatch · getFeaturedOfferExpectedPriceBatch · getCompetitiveSummary
⚠️ 依赖:需已安装并完成授权 `linkfox-amazon-store-auth`。应用需具备 Product Pricing 等相关角色/权限,否则上游可能返回 403。
---
调用规范(与 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(定价类 GET / POST)
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| region | string | 是 | NA / EU / FE |
| path | string | 是 | 不含主机名。示例:`products/pricing/v0/price`、`products/pricing/v0/items/{Asin}/offers`、`batches/products/pricing/v0/itemOffers`、`batches/products/pricing/2022-05-01/items/competitiveSummary` 等(见各节) |
| method | string | 是 | `GET` 或 `POST`(与上游一致) |
| amzAccessToken | string | 是 | /spApi/storeTokens 返回的 accessToken |
| queryString | string | 视操作 | 无 `?` 前缀。GET 定价类多 必填;POST 批量通常 无 query,以 Amazon 为准 |
| body | string | 视操作 | POST 时多为 JSON 字符串(与 put_listings_item 相同,见 listings references/api.md) |
| contentType | string | 视操作 | POST 带 body 时一般为 `application/json` |
网关响应:errcode、errmsg、httpStatus、contentType、body(字符串)。先 `errcode`,再 `httpStatus`,再解析 `body`。
白名单与错误码
path须在网关 `sp-api.developer-proxy.allowed-path-prefixes` 内。若 `errcode=1005`,需联系后端放行 `products/pricing/` 与 `batches/products/pricing/` 等前缀(以运维配置为准)。- 其它错误与
linkfox-amazon-store-report的 Developer Proxy 说明一致。
---
getPricing — Query 参数(写入 queryString)
官方参数名 大小写敏感。多值 `Asins` / `Skus` 采用重复键形式:Asins=B0...&Asins=B0...(本仓库脚本按此拼接)。
| 参数名 | 必填 | 说明 |
|---|---|---|
| MarketplaceId | 是 | 单个 marketplace id,例如美国 ATVPDKIKX0DER。与 Listings API 的 marketplaceIds 不同,此处为 单数键名 |
| ItemType | 是 | Asin 或 Sku(与下方 Asins / Skus 二选一对应) |
| Asins | 与 ItemType 对应 | 当 ItemType=Asin 时必填;最多 20 个 ASIN |
| Skus | 与 ItemType 对应 | 当 ItemType=Sku 时必填;最多 20 个卖家 SKU(注意 URL 编码) |
| ItemCondition | 否 | New、Used、Collectible、Refurbished、Club |
| OfferType | 否 | B2C 或 B2B;默认多为 B2C(以上游为准) |
速率(文档默认值,以账号实际为准)
- 约 0.5 req/s,burst 1(见官方 Usage plan 表)。
---
getCompetitivePricing — Query 参数(写入 queryString)
与 getPricing 相同:`MarketplaceId`、`ItemType`(Asin / Sku)、`Asins` 或 `Skus`(每请求最多 20 个,重复键拼接)。差异如下:
| 参数名 | 必填 | 说明 |
|---|---|---|
| CustomerType | 否 | Consumer 或 Business;从 消费者 / 企业买家 视角看定价信息,默认多为 Consumer(以上游为准) |
速率(文档默认值):约 0.5 req/s,burst 1(见 getCompetitivePricing Usage plan)。
getCompetitivePricing 没有 getPricing 的ItemCondition、OfferType参数;二者用途不同,勿混用字段名。
---
getListingOffers — Path 与 Query
- Path 模板(写入
developerProxy.path):
products/pricing/v0/listings/{SellerSKU}/offers其中 `{SellerSKU}` 为卖家 SKU,路径段须 百分号编码(与 get_listings_item 同理;脚本使用 urllib.parse.quote(..., safe=""))。
Query(写入 queryString)
| 参数名 | 必填 | 说明 |
|---|---|---|
| MarketplaceId | 是 | 单个 marketplace id |
| ItemCondition | 是 | New、Used、Collectible、Refurbished、Club |
| CustomerType | 否 | Consumer 或 Business(默认多为 Consumer,以上游为准) |
语义:针对单个 SKU 刊登返回较低报价类信息(官方描述为 lowest priced offers;具体结构见 getListingOffers)。
速率(文档默认值):约 1 req/s,burst 2(见官方 Usage plan)。
成功响应(摘要)
- `httpStatus=200` 时解析
body:get_pricing.py→ `pricing`;get_competitive_pricing.py→ `competitivePricing`;get_listing_offers.py→ `listingOffers`;get_item_offers.py→ `itemOffers`;post_item_offers_batch.py→ `itemOffersBatch`;post_listing_offers_batch.py→ `listingOffersBatch`;post_featured_offer_expected_price_batch.py→ `featuredOfferExpectedPriceBatch`;post_competitive_summary_batch.py→ `competitiveSummary`。
---
getItemOffers — Path 与 Query
- Path:
products/pricing/v0/items/{Asin}/offers({Asin}路径编码) - Query:`MarketplaceId`(必填)、`ItemCondition`(必填)、`CustomerType`(可选)
速率(文档默认值):约 0.5 req/s,burst 1(见 getItemOffers)。
---
批量 POST(ItemOffers / ListingOffers / FOEP / CompetitiveSummary)
上游均为 `POST` + JSON body,根字段为 `requests` 数组。子请求字段以 Amazon 模型为准;本仓库脚本在默认模式下将简化 JSON 展开为官方形状;若传 `useAmazonRequestShape`: true,则 `requests` 须已是 Amazon 原始对象(脚本只做条数校验)。
| 操作 | path | 子请求条数(脚本校验) | 文档速率(默认,以账号为准) |
|---|---|---|---|
| getItemOffersBatch | batches/products/pricing/v0/itemOffers | 1~20 | 约 0.1 req/s,burst 1 |
| getListingOffersBatch | batches/products/pricing/v0/listingOffers | 1~20 | 约 0.5 req/s,burst 1 |
| getFeaturedOfferExpectedPriceBatch | batches/products/pricing/2022-05-01/offer/featuredOfferExpectedPrice | 1~40 | 约 0.033 req/s,burst 1 |
| getCompetitiveSummary | batches/products/pricing/2022-05-01/items/competitiveSummary | 1~20 | 约 0.033 req/s,burst 1 |
Item / Listing Offers 批量子请求(简化 → 官方):每条展开为 `uri`(以 / 开头的资源路径,无 query)、`method`: GET、`MarketplaceId`、`ItemCondition`,以及可选 `CustomerType`、`headers`。Item 的 uri 形如 `/products/pricing/v0/items/{Asin}/offers`;Listing 的 uri 形如 `/products/pricing/v0/listings/{SellerSKU}/offers`(SKU 路径编码)。
getFeaturedOfferExpectedPriceBatch(简化):每条含 `marketplaceId`、`sku`、`segment`(对象,结构见官方);脚本补充 `uri`、`method`: POST。
getCompetitiveSummary(简化):每条含 `asin`、`marketplaceId`、`includedData`(非空字符串数组,如 featuredBuyingOptions),以及可选 `lowestPricedOffersInputs`;脚本补充 `uri`、`method`: POST。
---
脚本 JSON 入参(get_pricing.py)
与 Amazon Query 的对应关系:
| 脚本字段 | 必填 | 映射 |
|---|---|---|
| sellerId | 是 | 仅用于 /spApi/storeTokens |
| region | 是 | NA / EU / FE |
| marketplaceId | 是* | → MarketplaceId。若只提供 `marketplaceIds` 数组,则取 第一个 并 stderr 警告(与同系列 listing 脚本习惯一致) |
| itemType | 是 | Asin 或 Sku |
| asins | 条件 | itemType=Asin 时至少 1 个、≤20 |
| skus | 条件 | itemType=Sku 时至少 1 个、≤20 |
| itemCondition | 否 | → ItemCondition |
| offerType | 否 | → OfferType |
| skipDepCheck | 否 | true 时跳过 check_auth_dependency.py |
---
脚本 JSON 入参(get_competitive_pricing.py)
| 脚本字段 | 必填 | 映射 |
|---|---|---|
| sellerId | 是 | /spApi/storeTokens |
| region | 是 | NA / EU / FE |
| marketplaceId | 是* | → MarketplaceId;或 `marketplaceIds` 取第一个 |
| itemType | 是 | Asin 或 Sku |
| asins / skus | 条件 | 与 getPricing 相同(1~20) |
| customerType | 否 | → CustomerType:Consumer / Business |
| skipDepCheck | 否 | 同左 |
---
脚本 JSON 入参(get_listing_offers.py)
| 脚本字段 | 必填 | 映射 |
|---|---|---|
| sellerId | 是 | /spApi/storeTokens |
| region | 是 | NA / EU / FE |
| sku | 是 | 卖家 SKU → path 中的 {SellerSKU} |
| marketplaceId | 是* | → MarketplaceId;或 `marketplaceIds` 取第一个 |
| itemCondition | 是 | → ItemCondition |
| customerType | 否 | → CustomerType:Consumer / Business |
| skipDepCheck | 否 | 同左 |
---
脚本 JSON 入参(get_item_offers.py)
| 脚本字段 | 必填 | 说明 |
|---|---|---|
| sellerId / region | 是 | storeTokens |
| asin | 是 | path 中的 ASIN |
| marketplaceId | 是* | Query MarketplaceId |
| itemCondition | 是 | Query ItemCondition |
| customerType | 否 | Query CustomerType |
| skipDepCheck | 否 | 同左 |
---
脚本 JSON 入参(post_item_offers_batch.py / post_listing_offers_batch.py)
| 脚本字段 | 必填 | 说明 |
|---|---|---|
| sellerId / region | 是 | storeTokens |
| requests | 是 | 1~20;默认每项 item batch:asin+marketplaceId+itemCondition 或 listing batch:sku+marketplaceId+itemCondition |
| useAmazonRequestShape | 否 | true 时 requests 为 Amazon 原始子请求 |
| skipDepCheck | 否 | 同左 |
成功时 stdout 含 `requestBody`(已发送的 JSON 对象)。
---
脚本 JSON 入参(post_featured_offer_expected_price_batch.py)
| 脚本字段 | 必填 | 说明 |
|---|---|---|
| sellerId / region | 是 | storeTokens |
| requests | 是 | 1~40;每项 marketplaceId、sku、segment(或 useAmazonRequestShape) |
| useAmazonRequestShape / skipDepCheck | 否 | 同上 |
---
脚本 JSON 入参(post_competitive_summary_batch.py)
| 脚本字段 | 必填 | 说明 |
|---|---|---|
| sellerId / region | 是 | storeTokens |
| requests | 是 | 1~20;每项 asin、marketplaceId、includedData(数组),可选 lowestPricedOffersInputs |
| useAmazonRequestShape / skipDepCheck | 否 | 同上 |
---
curl 示例
1)取 `accessToken`
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)getPricing(按 ASIN)
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": "products/pricing/v0/price",
"method": "GET",
"amzAccessToken": "Atza|IwEBI...",
"queryString": "MarketplaceId=ATVPDKIKX0DER&ItemType=Asin&Asins=B08N5WRWNW&ItemCondition=New"
}'请将示例 ASIN / token 换为真实值;多 ASIN 时重复 Asins= 键。3)getCompetitivePricing(按 ASIN + 企业买家视角)
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": "products/pricing/v0/competitivePrice",
"method": "GET",
"amzAccessToken": "Atza|IwEBI...",
"queryString": "MarketplaceId=ATVPDKIKX0DER&ItemType=Asin&Asins=B08N5WRWNW&CustomerType=Business"
}'4)getListingOffers(单 SKU;path 中 SKU 若含特殊字符须先编码)
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": "products/pricing/v0/listings/My-Seller-SKU-001/offers",
"method": "GET",
"amzAccessToken": "Atza|IwEBI...",
"queryString": "MarketplaceId=ATVPDKIKX0DER&ItemCondition=New"
}'5)getItemOffersBatch(POST body 示意;`requests` 以实网为准)
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": "batches/products/pricing/v0/itemOffers",
"method": "POST",
"amzAccessToken": "Atza|IwEBI...",
"contentType": "application/json",
"body": "{\"requests\":[{\"uri\":\"/products/pricing/v0/items/B08N5WRWNW/offers\",\"method\":\"GET\",\"MarketplaceId\":\"ATVPDKIKX0DER\",\"ItemCondition\":\"New\"}]}"
}'---
Feedback API
- POST
https://skill-api.linkfox.com/api/v1/public/feedback - Content-Type:
application/json
{
"skillName": "linkfox-amazon-store-pricing",
"sentiment": "POSITIVE",
"category": "OTHER",
"content": "Product Pricing(含批量与 2022-05-01)结果符合预期。"
}"""Shared helpers for linkfox-amazon-store-pricing scripts (storeTokens + developerProxy)."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
REQUIRED_SKILL = "linkfox-amazon-store-auth"
DEPENDENCY_EXIT_CODE = 42
API_BASE_URL = os.environ.get("STORE_API_BASE_URL") or os.environ.get(
"SPAPI_BASE_URL", "https://tool-gateway.linkfox.com"
)
STORE_TOKENS_ENDPOINT = f"{API_BASE_URL.rstrip('/')}/spApi/storeTokens"
DEVELOPER_PROXY_ENDPOINT = f"{API_BASE_URL.rstrip('/')}/spApi/developerProxy"
def ensure_auth_skill_available(caller_script: str) -> None:
here = Path(__file__).resolve().parent
checker = here / "check_auth_dependency.py"
if not checker.exists():
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": f"check_auth_dependency.py not found next to {caller_script}",
}
print(f"DEPENDENCY_MISSING: {json.dumps(payload, ensure_ascii=False)}", file=sys.stderr)
sys.exit(DEPENDENCY_EXIT_CODE)
try:
result = subprocess.run(
[sys.executable, str(checker)],
capture_output=True,
text=True,
timeout=10,
)
except Exception as exc: # pragma: no cover
payload = {"missingSkill": REQUIRED_SKILL, "reason": str(exc)}
print(f"DEPENDENCY_MISSING: {json.dumps(payload, ensure_ascii=False)}", file=sys.stderr)
sys.exit(DEPENDENCY_EXIT_CODE)
if result.stderr:
sys.stderr.write(result.stderr)
if not result.stderr.endswith("\n"):
sys.stderr.write("\n")
if result.returncode != 0:
sys.exit(DEPENDENCY_EXIT_CODE)
def get_api_key() -> str:
key = os.environ.get("LINKFOXAGENT_API_KEY")
if not key:
print(
"API Key not configured. Set:\n export LINKFOXAGENT_API_KEY=<your-key>",
file=sys.stderr,
)
sys.exit(1)
return key
def call_api(endpoint: str, params: dict, timeout: int = 120) -> dict:
api_key = get_api_key()
data = json.dumps(params).encode("utf-8")
req = Request(
endpoint,
data=data,
headers={
"Authorization": api_key,
"Content-Type": "application/json",
"User-Agent": "LinkFox-Skill/1.0",
},
method="POST",
)
try:
with urlopen(req, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as e:
body = e.read().decode("utf-8") if e.fp else ""
return {"error": f"HTTP {e.code}: {e.reason}", "details": body}
except URLError as e:
return {"error": f"Connection failed: {e.reason}"}
def get_store_tokens(seller_id: str, region: str) -> dict:
return call_api(STORE_TOKENS_ENDPOINT, {"sellerId": seller_id, "region": region})
def developer_proxy_get(
region: str,
path: str,
access_token: str,
query_string: Optional[str] = None,
) -> 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 developer_proxy_post_json(
region: str,
path: str,
access_token: str,
body_obj: dict[str, Any],
) -> dict:
params: dict = {
"region": region,
"path": path,
"method": "POST",
"amzAccessToken": access_token,
"body": json.dumps(body_obj, ensure_ascii=False),
"contentType": "application/json",
}
return call_api(DEVELOPER_PROXY_ENDPOINT, params)
def resolve_marketplace_id(params: dict, api_name: str) -> str:
mid = params.get("marketplaceId")
if mid is None and params.get("marketplaceIds") is not None:
mids = params["marketplaceIds"]
if isinstance(mids, list) and mids:
mid = mids[0]
if len(mids) > 1:
print(
f"⚠️ Warning: {api_name} expects a single MarketplaceId; using first marketplaceIds only.",
file=sys.stderr,
)
elif isinstance(mids, str) and mids.strip():
mid = mids.strip()
if mid is None or (isinstance(mid, str) and not mid.strip()):
raise ValueError("Missing marketplaceId (or non-empty marketplaceIds)")
return str(mid).strip()
#!/usr/bin/env python3
"""
Dependency Check - linkfox-amazon-store-pricing
================================================
本脚本用于判断当前运行环境里是否已经安装 / 加载了依赖 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_pricing.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()
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:
out = dict(payload)
out["status"] = "ok" if ok else "missing"
print(json.dumps(out, ensure_ascii=False, indent=2))
print(f"{prefix} {body}", file=sys.stderr)
def main() -> None:
parser = argparse.ArgumentParser(description="Check required dependency skill availability.")
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON on stdout.")
args = parser.parse_args()
found = locate_dependency()
if found is not None:
emit(
as_json=args.json,
ok=True,
payload={
"skill": REQUIRED_SKILL,
"skillMdPath": str(found),
},
)
sys.exit(0)
payload = {
"missingSkill": REQUIRED_SKILL,
"reason": (
f"linkfox-amazon-store-pricing 依赖 `{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 — getCompetitivePricing (SP-API Product Pricing v0)
================================================================
通过 LinkFox 店铺网关 **POST /spApi/developerProxy** 转发 **GET getCompetitivePricing**,
与 `get_pricing.py` 使用同一套代理接口。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getcompetitivepricing
Usage:
python get_competitive_pricing.py '{
"sellerId": "A1BCDEFGHIJK2",
"region": "NA",
"marketplaceId": "ATVPDKIKX0DER",
"itemType": "Asin",
"asins": ["B08N5WRWNW"]
}'
Optional JSON fields:
- customerType: Consumer | Business(默认 Consumer,不传则由上游决定)
- marketplaceIds: 若提供数组则仅取第一个作为 MarketplaceId
- skipDepCheck: boolean
"""
from __future__ import annotations
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
COMPETITIVE_PRICE_PATH = "products/pricing/v0/competitivePrice"
MAX_IDENTIFIERS = 20
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_competitive_pricing.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 _normalize_id_list(raw: object, field_name: str) -> List[str]:
if raw is None:
return []
if isinstance(raw, str):
s = raw.strip()
return [s] if s else []
if isinstance(raw, list):
return [str(x).strip() for x in raw if str(x).strip()]
print(f"{field_name} must be a string or array of strings", file=sys.stderr)
sys.exit(1)
def _build_query_string(
marketplace_id: str,
item_type: str,
asins: List[str],
skus: List[str],
customer_type: Optional[str],
) -> str:
parts: list[str] = [
f"MarketplaceId={quote(marketplace_id, safe='')}",
f"ItemType={quote(item_type, safe='')}",
]
it = item_type.strip()
if it == "Asin":
for a in asins[:MAX_IDENTIFIERS]:
parts.append(f"Asins={quote(a, safe='')}")
elif it == "Sku":
for s in skus[:MAX_IDENTIFIERS]:
parts.append(f"Skus={quote(s, safe='')}")
else:
raise ValueError('itemType must be "Asin" or "Sku" (case-sensitive per Amazon)')
if customer_type:
parts.append(f"CustomerType={quote(customer_type.strip(), safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: get_competitive_pricing.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds[0]), "
'itemType ("Asin"|"Sku"), and asins[] or skus[] (1..20 ids).\n'
"Example: get_competitive_pricing.py "
'\'{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER",'
'"itemType":"Asin","asins":["B0XXXXXXXX"]}\'',
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", "itemType"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
mid = params.get("marketplaceId")
if mid is None and params.get("marketplaceIds") is not None:
mids = params["marketplaceIds"]
if isinstance(mids, list) and mids:
mid = mids[0]
if len(mids) > 1:
print(
"⚠️ Warning: getCompetitivePricing expects a single MarketplaceId; "
"using first marketplaceIds only.",
file=sys.stderr,
)
elif isinstance(mids, str) and mids.strip():
mid = mids.strip()
if mid is None or (isinstance(mid, str) and not mid.strip()):
print("Missing marketplaceId (or non-empty marketplaceIds)", file=sys.stderr)
sys.exit(1)
marketplace_id = str(mid).strip()
seller_id = str(params["sellerId"])
region = str(params["region"])
item_type = str(params["itemType"]).strip()
asins = _normalize_id_list(params.get("asins"), "asins")
skus = _normalize_id_list(params.get("skus"), "skus")
if item_type == "Asin":
ids = asins
if skus:
print(
"When itemType is Asin, do not pass skus (prefer asins only).",
file=sys.stderr,
)
elif item_type == "Sku":
ids = skus
if asins:
print(
"When itemType is Sku, do not pass asins (prefer skus only).",
file=sys.stderr,
)
else:
ids = []
if not ids:
print("Provide non-empty asins (for ItemType Asin) or skus (for ItemType Sku).", file=sys.stderr)
sys.exit(1)
if len(ids) > MAX_IDENTIFIERS:
print(f"At most {MAX_IDENTIFIERS} Asins or Skus per request.", file=sys.stderr)
sys.exit(1)
customer_type = params.get("customerType")
if customer_type is not None:
customer_type = str(customer_type)
try:
query_string = _build_query_string(
marketplace_id,
item_type,
asins if item_type == "Asin" else [],
skus if item_type == "Sku" else [],
customer_type,
)
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
path = COMPETITIVE_PRICE_PATH
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: dict = {
"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["competitivePricing"] = json.loads(body_raw)
except json.JSONDecodeError:
out["competitivePricing"] = None
out["competitivePricingRaw"] = body_raw
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — getItemOffers (SP-API Product Pricing v0)
=========================================================
GET 单个 ASIN 在指定站点、成色下的低价报价类信息(以 Amazon 响应为准)。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getitemoffers
Usage:
python get_item_offers.py '{
"sellerId": "A1BCDEFGHIJK2",
"region": "NA",
"asin": "B08N5WRWNW",
"marketplaceId": "ATVPDKIKX0DER",
"itemCondition": "New"
}'
Optional: customerType (Consumer|Business), marketplaceIds, skipDepCheck
"""
from __future__ import annotations
import json
import sys
from typing import Optional
from urllib.parse import quote
from _spapi_pricing_common import (
developer_proxy_get,
ensure_auth_skill_available,
get_store_tokens,
resolve_marketplace_id,
)
def _path(asin: str) -> str:
return f"products/pricing/v0/items/{quote(asin.strip(), safe='')}/offers"
def _query(mid: str, item_condition: str, customer_type: Optional[str]) -> str:
parts = [
f"MarketplaceId={quote(mid, safe='')}",
f"ItemCondition={quote(item_condition.strip(), safe='')}",
]
if customer_type:
parts.append(f"CustomerType={quote(customer_type.strip(), safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: get_item_offers.py '<JSON>'\n"
"Required: sellerId, region, asin, marketplaceId (or marketplaceIds[0]), "
"itemCondition.\n"
'Example: get_item_offers.py '
'\'{"sellerId":"A1...","region":"NA","asin":"B0...","marketplaceId":"ATVPDKIKX0DER",'
'"itemCondition":"New"}\'',
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("get_item_offers.py")
for f in ("sellerId", "region", "asin", "itemCondition"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
try:
marketplace_id = resolve_marketplace_id(params, "getItemOffers")
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
asin = str(params["asin"]).strip()
if not asin:
print("asin must be non-empty", file=sys.stderr)
sys.exit(1)
seller_id = str(params["sellerId"])
region = str(params["region"])
item_condition = str(params["itemCondition"])
ct = params.get("customerType")
if ct is not None:
ct = str(ct)
path = _path(asin)
q = _query(marketplace_id, item_condition, ct)
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"], q)
out: dict = {"developerProxy": proxy, "resolvedPath": path, "queryString": q}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
raw = proxy.get("body") or "{}"
try:
out["itemOffers"] = json.loads(raw)
except json.JSONDecodeError:
out["itemOffers"] = None
out["itemOffersRaw"] = raw
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — getListingOffers (SP-API Product Pricing v0)
===========================================================
通过 LinkFox 店铺网关 **POST /spApi/developerProxy** 转发 **GET getListingOffers**,
返回**单个**卖家 SKU 在指定站点、指定成色下的最低报价类信息(以 Amazon 响应为准)。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getlistingoffers
Usage:
python get_listing_offers.py '{
"sellerId": "A1BCDEFGHIJK2",
"region": "NA",
"sku": "My-Seller-SKU-001",
"marketplaceId": "ATVPDKIKX0DER",
"itemCondition": "New"
}'
Optional JSON fields:
- customerType: Consumer | Business
- marketplaceIds: 若提供数组则仅取第一个作为 MarketplaceId
- skipDepCheck: boolean
"""
from __future__ import annotations
from typing import 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
LISTING_OFFERS_PREFIX = "products/pricing/v0/listings"
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_listing_offers.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_listing_offers(sku: str) -> str:
enc_sku = quote(sku, safe="")
return f"{LISTING_OFFERS_PREFIX}/{enc_sku}/offers"
def _build_query_string(
marketplace_id: str,
item_condition: str,
customer_type: Optional[str],
) -> str:
parts: list[str] = [
f"MarketplaceId={quote(marketplace_id, safe='')}",
f"ItemCondition={quote(item_condition.strip(), safe='')}",
]
if customer_type:
parts.append(f"CustomerType={quote(customer_type.strip(), safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: get_listing_offers.py '<JSON>'\n"
"Required: sellerId, region, sku (seller SKU), marketplaceId (or marketplaceIds[0]), "
"itemCondition (New|Used|Collectible|Refurbished|Club).\n"
"Example: get_listing_offers.py "
'\'{"sellerId":"A1...","region":"NA","sku":"MY-SKU","marketplaceId":"ATVPDKIKX0DER",'
'"itemCondition":"New"}\'',
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", "itemCondition"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
mid = params.get("marketplaceId")
if mid is None and params.get("marketplaceIds") is not None:
mids = params["marketplaceIds"]
if isinstance(mids, list) and mids:
mid = mids[0]
if len(mids) > 1:
print(
"⚠️ Warning: getListingOffers expects a single MarketplaceId; "
"using first marketplaceIds only.",
file=sys.stderr,
)
elif isinstance(mids, str) and mids.strip():
mid = mids.strip()
if mid is None or (isinstance(mid, str) and not mid.strip()):
print("Missing marketplaceId (or non-empty marketplaceIds)", file=sys.stderr)
sys.exit(1)
marketplace_id = str(mid).strip()
seller_id = str(params["sellerId"])
region = str(params["region"])
sku = str(params["sku"])
if not sku.strip():
print("sku must be non-empty", file=sys.stderr)
sys.exit(1)
item_condition = str(params["itemCondition"])
customer_type = params.get("customerType")
if customer_type is not None:
customer_type = str(customer_type)
query_string = _build_query_string(marketplace_id, item_condition, customer_type)
path = _path_for_listing_offers(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: dict = {
"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["listingOffers"] = json.loads(body_raw)
except json.JSONDecodeError:
out["listingOffers"] = None
out["listingOffersRaw"] = body_raw
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — getPricing (SP-API Product Pricing v0)
======================================================
通过 LinkFox 店铺网关 **POST /spApi/developerProxy** 转发 **GET getPricing**,
与 `linkfox-amazon-store-report` / `linkfox-amazon-store-listings` 使用同一套代理接口。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getpricing
Usage:
python get_pricing.py '{
"sellerId": "A1BCDEFGHIJK2",
"region": "NA",
"marketplaceId": "ATVPDKIKX0DER",
"itemType": "Asin",
"asins": ["B08N5WRWNW"]
}'
# 按 SKU(最多 20 个)
python get_pricing.py '{"sellerId":"...","region":"NA","marketplaceId":"ATVPDKIKX0DER","itemType":"Sku","skus":["MY-SKU-1"]}'
Optional JSON fields:
- itemCondition: New | Used | Collectible | Refurbished | Club
- offerType: B2C | B2B(默认 B2C,不传则由上游决定)
- marketplaceIds: 若提供数组则仅取第一个作为 MarketplaceId(与 listing 系列脚本习惯一致)
- skipDepCheck: boolean
"""
from __future__ import annotations
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
PRICING_PATH = "products/pricing/v0/price"
MAX_IDENTIFIERS = 20
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_pricing.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 _normalize_id_list(raw: object, field_name: str) -> List[str]:
if raw is None:
return []
if isinstance(raw, str):
s = raw.strip()
return [s] if s else []
if isinstance(raw, list):
return [str(x).strip() for x in raw if str(x).strip()]
print(f"{field_name} must be a string or array of strings", file=sys.stderr)
sys.exit(1)
def _build_query_string(
marketplace_id: str,
item_type: str,
asins: List[str],
skus: List[str],
item_condition: Optional[str],
offer_type: Optional[str],
) -> str:
parts: list[str] = [
f"MarketplaceId={quote(marketplace_id, safe='')}",
f"ItemType={quote(item_type, safe='')}",
]
it = item_type.strip()
if it == "Asin":
for a in asins[:MAX_IDENTIFIERS]:
parts.append(f"Asins={quote(a, safe='')}")
elif it == "Sku":
for s in skus[:MAX_IDENTIFIERS]:
parts.append(f"Skus={quote(s, safe='')}")
else:
raise ValueError('itemType must be "Asin" or "Sku" (case-sensitive per Amazon)')
if item_condition:
parts.append(f"ItemCondition={quote(item_condition.strip(), safe='')}")
if offer_type:
parts.append(f"OfferType={quote(offer_type.strip(), safe='')}")
return "&".join(parts)
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: get_pricing.py '<JSON>'\n"
"Required: sellerId, region, marketplaceId (or marketplaceIds[0]), "
'itemType ("Asin"|"Sku"), and asins[] or skus[] (1..20 ids).\n'
"Example: get_pricing.py "
'\'{"sellerId":"A1...","region":"NA","marketplaceId":"ATVPDKIKX0DER",'
'"itemType":"Asin","asins":["B0XXXXXXXX"]}\'',
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", "itemType"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
mid = params.get("marketplaceId")
if mid is None and params.get("marketplaceIds") is not None:
mids = params["marketplaceIds"]
if isinstance(mids, list) and mids:
mid = mids[0]
if len(mids) > 1:
print(
"⚠️ Warning: getPricing expects a single MarketplaceId; using first marketplaceIds only.",
file=sys.stderr,
)
elif isinstance(mids, str) and mids.strip():
mid = mids.strip()
if mid is None or (isinstance(mid, str) and not mid.strip()):
print("Missing marketplaceId (or non-empty marketplaceIds)", file=sys.stderr)
sys.exit(1)
marketplace_id = str(mid).strip()
seller_id = str(params["sellerId"])
region = str(params["region"])
item_type = str(params["itemType"]).strip()
asins = _normalize_id_list(params.get("asins"), "asins")
skus = _normalize_id_list(params.get("skus"), "skus")
if item_type == "Asin":
ids = asins
if skus:
print("When itemType is Asin, do not pass skus (ignored if both set; prefer asins only).", file=sys.stderr)
elif item_type == "Sku":
ids = skus
if asins:
print("When itemType is Sku, do not pass asins (ignored if both set; prefer skus only).", file=sys.stderr)
else:
ids = []
if not ids:
print("Provide non-empty asins (for ItemType Asin) or skus (for ItemType Sku).", file=sys.stderr)
sys.exit(1)
if len(ids) > MAX_IDENTIFIERS:
print(f"At most {MAX_IDENTIFIERS} Asins or Skus per request.", file=sys.stderr)
sys.exit(1)
item_condition = params.get("itemCondition")
if item_condition is not None:
item_condition = str(item_condition)
offer_type = params.get("offerType")
if offer_type is not None:
offer_type = str(offer_type)
try:
query_string = _build_query_string(
marketplace_id,
item_type,
asins if item_type == "Asin" else [],
skus if item_type == "Sku" else [],
item_condition,
offer_type,
)
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
path = PRICING_PATH
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: dict = {
"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["pricing"] = json.loads(body_raw)
except json.JSONDecodeError:
out["pricing"] = None
out["pricingRaw"] = body_raw
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — getCompetitiveSummary (Product Pricing 2022-05-01)
=================================================================
POST competitiveSummary 批量接口(每批 1~20 条子请求)。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getcompetitivesummary
JSON 入参(简化):
sellerId, region,
requests: [
{
"asin", "marketplaceId",
"includedData": ["featuredBuyingOptions", ...],
"lowestPricedOffersInputs": [...] // 可选,仅当 includedData 含 lowestPricedOffers 时需要
}, ...
]
可选 useAmazonRequestShape: true — requests 为 Amazon 完整子请求(须含 uri、method 等)。
"""
from __future__ import annotations
import json
import sys
from _spapi_pricing_common import (
developer_proxy_post_json,
ensure_auth_skill_available,
get_store_tokens,
)
PATH_BATCH = "batches/products/pricing/2022-05-01/items/competitiveSummary"
URI_SUB = "/products/pricing/2022-05-01/items/competitiveSummary"
MAX_REQUESTS = 20
def _expand_simple(req: dict) -> dict:
asin = str(req.get("asin") or "").strip()
mid = str(req.get("marketplaceId") or "").strip()
if not asin or not mid:
raise ValueError("each request needs asin and marketplaceId")
inc = req.get("includedData")
if not isinstance(inc, list) or not inc:
raise ValueError("each request needs includedData (non-empty array of strings)")
out: dict = {
"uri": URI_SUB,
"method": "POST",
"asin": asin,
"marketplaceId": mid,
"includedData": inc,
}
if "lowestPricedOffersInputs" in req:
out["lowestPricedOffersInputs"] = req["lowestPricedOffersInputs"]
return out
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: post_competitive_summary_batch.py '<JSON>'\n"
f"Required: sellerId, region, requests (1..{MAX_REQUESTS}).\n"
"Each item: asin, marketplaceId, includedData [, lowestPricedOffersInputs].",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("post_competitive_summary_batch.py")
for f in ("sellerId", "region", "requests"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
raw = params["requests"]
if not isinstance(raw, list) or not raw:
print("requests must be a non-empty array", file=sys.stderr)
sys.exit(1)
if len(raw) > MAX_REQUESTS:
print(f"At most {MAX_REQUESTS} batch sub-requests.", file=sys.stderr)
sys.exit(1)
use_amazon = bool(params.get("useAmazonRequestShape"))
try:
requests_out = raw if use_amazon else [_expand_simple(r) for r in raw]
except (KeyError, ValueError) as e:
print(str(e), file=sys.stderr)
sys.exit(1)
body_obj = {"requests": requests_out}
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_post_json(region, PATH_BATCH, tokens["accessToken"], body_obj)
out: dict = {
"developerProxy": proxy,
"resolvedPath": PATH_BATCH,
"requestBody": body_obj,
}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
br = proxy.get("body") or "{}"
try:
out["competitiveSummary"] = json.loads(br)
except json.JSONDecodeError:
out["competitiveSummary"] = None
out["competitiveSummaryRaw"] = br
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — getFeaturedOfferExpectedPriceBatch (Product Pricing 2022-05-01)
==============================================================================
POST FOEP 批量接口(单批最多 40 条子请求,以 Amazon 文档为准)。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getfeaturedofferexpectedpricebatch
JSON 入参(简化):
sellerId, region,
requests: [ { "marketplaceId", "sku", "segment" }, ... ]
segment 须符合 SP-API 模型(地理/配送等),见官方文档。
可选 useAmazonRequestShape: true — requests 为 Amazon 完整子请求对象(含 uri、method 等)。
"""
from __future__ import annotations
import json
import sys
from _spapi_pricing_common import (
developer_proxy_post_json,
ensure_auth_skill_available,
get_store_tokens,
)
PATH_BATCH = "batches/products/pricing/2022-05-01/offer/featuredOfferExpectedPrice"
URI_SUB = "/products/pricing/2022-05-01/offer/featuredOfferExpectedPrice"
MAX_REQUESTS = 40
def _expand_simple(req: dict) -> dict:
mid = str(req.get("marketplaceId") or "").strip()
sku = str(req.get("sku") or "").strip()
if not mid or not sku:
raise ValueError("each request needs marketplaceId and sku")
if "segment" not in req or not isinstance(req["segment"], dict):
raise ValueError("each request needs segment (object) per Amazon schema")
return {
"uri": URI_SUB,
"method": "POST",
"marketplaceId": mid,
"sku": sku,
"segment": req["segment"],
}
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: post_featured_offer_expected_price_batch.py '<JSON>'\n"
f"Required: sellerId, region, requests (1..{MAX_REQUESTS}).\n"
"Each item: marketplaceId, sku, segment (object).",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("post_featured_offer_expected_price_batch.py")
for f in ("sellerId", "region", "requests"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
raw = params["requests"]
if not isinstance(raw, list) or not raw:
print("requests must be a non-empty array", file=sys.stderr)
sys.exit(1)
if len(raw) > MAX_REQUESTS:
print(f"At most {MAX_REQUESTS} batch sub-requests.", file=sys.stderr)
sys.exit(1)
use_amazon = bool(params.get("useAmazonRequestShape"))
try:
requests_out = raw if use_amazon else [_expand_simple(r) for r in raw]
except (KeyError, ValueError) as e:
print(str(e), file=sys.stderr)
sys.exit(1)
body_obj = {"requests": requests_out}
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_post_json(region, PATH_BATCH, tokens["accessToken"], body_obj)
out: dict = {
"developerProxy": proxy,
"resolvedPath": PATH_BATCH,
"requestBody": body_obj,
}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
br = proxy.get("body") or "{}"
try:
out["featuredOfferExpectedPriceBatch"] = json.loads(br)
except json.JSONDecodeError:
out["featuredOfferExpectedPriceBatch"] = None
out["featuredOfferExpectedPriceBatchRaw"] = br
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — getItemOffersBatch (SP-API Product Pricing v0)
=============================================================
POST 批量查询多个 ASIN 的 getItemOffers(每批 1~20 条子请求)。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getitemoffersbatch
JSON 入参(简化):
sellerId, region,
requests: [ { "asin", "marketplaceId", "itemCondition", "customerType"?, "headers"? }, ... ]
脚本将每条展开为 Amazon 所需的 uri(/products/pricing/v0/items/{Asin}/offers)、method GET、
MarketplaceId、ItemCondition 等。若需完全自定义子请求体,可传 useAmazonRequestShape: true,
此时 requests 须为 Amazon 原始对象数组(仍须 1~20 条)。
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_pricing_common import (
developer_proxy_post_json,
ensure_auth_skill_available,
get_store_tokens,
)
PATH_BATCH = "batches/products/pricing/v0/itemOffers"
MAX_REQUESTS = 20
def _expand_simple(req: dict) -> dict:
asin = str(req["asin"]).strip()
if not asin:
raise ValueError("each request needs non-empty asin")
mid = str(req.get("marketplaceId") or "").strip()
if not mid:
raise ValueError("each request needs marketplaceId")
ic = str(req.get("itemCondition") or "").strip()
if not ic:
raise ValueError("each request needs itemCondition")
uri = f"/products/pricing/v0/items/{quote(asin, safe='')}/offers"
out: dict = {
"uri": uri,
"method": "GET",
"MarketplaceId": mid,
"ItemCondition": ic,
}
if req.get("customerType"):
out["CustomerType"] = str(req["customerType"]).strip()
if req.get("headers") is not None:
out["headers"] = req["headers"]
return out
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: post_item_offers_batch.py '<JSON>'\n"
f"Required: sellerId, region, requests (1..{MAX_REQUESTS} items).\n"
"Each item: asin, marketplaceId, itemCondition [, customerType, headers].",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("post_item_offers_batch.py")
for f in ("sellerId", "region", "requests"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
raw = params["requests"]
if not isinstance(raw, list) or not raw:
print("requests must be a non-empty array", file=sys.stderr)
sys.exit(1)
if len(raw) > MAX_REQUESTS:
print(f"At most {MAX_REQUESTS} batch sub-requests.", file=sys.stderr)
sys.exit(1)
use_amazon = bool(params.get("useAmazonRequestShape"))
try:
if use_amazon:
requests_out = raw
else:
requests_out = [_expand_simple(r) for r in raw]
except (KeyError, ValueError) as e:
print(str(e), file=sys.stderr)
sys.exit(1)
body_obj = {"requests": requests_out}
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_post_json(region, PATH_BATCH, tokens["accessToken"], body_obj)
out: dict = {
"developerProxy": proxy,
"resolvedPath": PATH_BATCH,
"requestBody": body_obj,
}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
br = proxy.get("body") or "{}"
try:
out["itemOffersBatch"] = json.loads(br)
except json.JSONDecodeError:
out["itemOffersBatch"] = None
out["itemOffersBatchRaw"] = br
print(json.dumps(out, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Store — getListingOffersBatch (SP-API Product Pricing v0)
==================================================================
POST 批量查询多个卖家 SKU 的 getListingOffers(每批 1~20 条子请求)。
官方参考: https://developer-docs.amazon.com/sp-api/reference/getlistingoffersbatch
JSON 入参(简化):
sellerId, region,
requests: [ { "sku", "marketplaceId", "itemCondition", "customerType"?, "headers"? }, ... ]
可选 useAmazonRequestShape: true 且 requests 为 Amazon 原始子请求数组。
"""
from __future__ import annotations
import json
import sys
from urllib.parse import quote
from _spapi_pricing_common import (
developer_proxy_post_json,
ensure_auth_skill_available,
get_store_tokens,
)
PATH_BATCH = "batches/products/pricing/v0/listingOffers"
MAX_REQUESTS = 20
def _expand_simple(req: dict) -> dict:
sku = str(req["sku"]).strip()
if not sku:
raise ValueError("each request needs non-empty sku")
mid = str(req.get("marketplaceId") or "").strip()
if not mid:
raise ValueError("each request needs marketplaceId")
ic = str(req.get("itemCondition") or "").strip()
if not ic:
raise ValueError("each request needs itemCondition")
uri = f"/products/pricing/v0/listings/{quote(sku, safe='')}/offers"
out: dict = {
"uri": uri,
"method": "GET",
"MarketplaceId": mid,
"ItemCondition": ic,
}
if req.get("customerType"):
out["CustomerType"] = str(req["customerType"]).strip()
if req.get("headers") is not None:
out["headers"] = req["headers"]
return out
def main() -> None:
if len(sys.argv) < 2:
print(
"Usage: post_listing_offers_batch.py '<JSON>'\n"
f"Required: sellerId, region, requests (1..{MAX_REQUESTS}).\n"
"Each item: sku, marketplaceId, itemCondition [, customerType, headers].",
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
if not params.get("skipDepCheck"):
ensure_auth_skill_available("post_listing_offers_batch.py")
for f in ("sellerId", "region", "requests"):
if f not in params:
print(f"Missing required field: {f}", file=sys.stderr)
sys.exit(1)
raw = params["requests"]
if not isinstance(raw, list) or not raw:
print("requests must be a non-empty array", file=sys.stderr)
sys.exit(1)
if len(raw) > MAX_REQUESTS:
print(f"At most {MAX_REQUESTS} batch sub-requests.", file=sys.stderr)
sys.exit(1)
use_amazon = bool(params.get("useAmazonRequestShape"))
try:
requests_out = raw if use_amazon else [_expand_simple(r) for r in raw]
except (KeyError, ValueError) as e:
print(str(e), file=sys.stderr)
sys.exit(1)
body_obj = {"requests": requests_out}
seller_id = str(params["sellerId"])
region = str(params["region"])
tokens = get_store_tokens(seller_id, region)
if "error" in tokens or "accessToken" not in tokens:
print(json.dumps(tokens, indent=2, ensure_ascii=False))
sys.exit(1)
proxy = developer_proxy_post_json(region, PATH_BATCH, tokens["accessToken"], body_obj)
out: dict = {
"developerProxy": proxy,
"resolvedPath": PATH_BATCH,
"requestBody": body_obj,
}
if proxy.get("errcode") == 200 and proxy.get("httpStatus") == 200:
br = proxy.get("body") or "{}"
try:
out["listingOffersBatch"] = json.loads(br)
except json.JSONDecodeError:
out["listingOffersBatch"] = None
out["listingOffersBatchRaw"] = br
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())