
Linkfox Amazon Store Auth
- 232 installs
- 64 repo stars
- Updated August 3, 2026
- linkfox-ai/linkfox-skills
Helps with ai & agent building tasks.
About
linkfox-amazon-store-auth is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- linkfox-amazon-store-auth
- AI & Agent Building
- AI-coding skill
Linkfox Amazon Store Auth by the numbers
- 232 all-time installs (skills.sh)
- +35 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #2,650 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-authAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 232 |
|---|---|
| repo stars | ★ 64 |
| Last updated | August 3, 2026 |
| Repository | linkfox-ai/linkfox-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Amazon 店铺授权与管理
本 skill 负责 亚马逊卖家店铺的 OAuth 授权、已授权店铺列表、访问令牌获取与刷新,是拉取报告、查询库存、同步订单等所有下游操作的前置依赖。
📌 Related skill:如果用户需要 拉取亚马逊店铺报告(库存 / 订单 / 销售 / 财务报告等),请切换到 linkfox-amazon-store-report。该 skill 依赖本 skill 提供的授权与令牌能力。Core Concepts
Selling Partner API 是亚马逊为卖家提供的官方接口。本 skill 负责 OAuth 2.0 授权流程与令牌生命周期管理:
授权流程:生成授权 URL → 用户在 Amazon 完成授权 → Amazon 回调并附带授权码 → 系统用授权码换取令牌 → 令牌安全保存。
店铺名(`sellerName`)必填:调用 /spApi/authorizeUrl 前必须向用户询问并获取一个清晰、非空的店铺名。它用来在"已授权店铺列表"中标记该账号;不要留空或使用空白字符串。
令牌生命周期:accessToken 通常 1 小时过期;refreshToken 用于在不重新授权的前提下续签新的 accessToken。
Data Fields
Authorization URL Response
| Field | Type | Description |
|---|---|---|
| authorizeUrl | string | 让用户在浏览器打开的 Amazon 授权链接 |
Authorized Store Item
| Field | Type | Description |
|---|---|---|
| sellerId | string | Amazon Seller ID (Merchant ID) |
| sellerName | string | 店铺名(授权时必填) |
| region | string | 市场区域代码 NA / EU / FE |
Store Tokens
| Field | Type | Description |
|---|---|---|
| accessToken | string | 调用亚马逊开放接口的凭证 |
| refreshToken | string | 用于续签 accessToken |
| expiresIn | integer | accessToken 过期秒数 |
| tokenType | string | 通常为 "bearer" |
Supported Regions
| Code | Name | Marketplaces |
|---|---|---|
| NA | 北美 | 美国、加拿大、墨西哥 |
| EU | 欧洲 | 英国、德国、法国、意大利、西班牙、荷兰等 |
| FE | 远东 | 日本、澳大利亚、新加坡、印度 |
默认区域为 NA。当用户未指定区域时,使用 NA。
API Usage
本 skill 经 LinkFox 网关调用店铺授权相关接口,详见 references/api.md。
Available Scripts
scripts/authorize_url.py— 为新店铺生成授权 URL(sellerName必填)scripts/authorized_stores.py— 列出所有已授权店铺scripts/refresh_token.py— 刷新某店铺的访问令牌scripts/store_tokens.py— 查询某店铺的访问令牌(供下游 skill 使用)
Usage Scenarios
Scenario 1: Authorize New Store
User request:「我要授权我的亚马逊北美站点」
Steps: 1. 询问店铺名 `sellerName`(若用户未提供)。/spApi/authorizeUrl 要求 sellerName 为非空字符串;向用户说明这只是在 LinkFox 里识别店铺的标签,建议与 Seller Central 后台名字保持一致。 2. 调用 /spApi/authorizeUrl,传入 region 与 sellerName 3. 把返回的 authorizeUrl 给用户,让其在浏览器中打开 4. 用户在 Amazon 完成授权 → Amazon 回调系统 → 系统自动保存授权 5. 可选:调用 /spApi/authorizedStores 确认授权成功
Scenario 2: View Authorized Stores
User request:「列一下我已授权的亚马逊店铺」
Steps: 1. 调用 /spApi/authorizedStores 2. 展示店铺列表(sellerName / sellerId / region) 3. 按 sellerId、region 排序
Scenario 3: Refresh Expired Token
User request:「我店铺的令牌过期了,帮我刷新」
Steps: 1. 调用 /spApi/refreshToken,传入 sellerId(可选 region) 2. 返回新的 accessToken / refreshToken 3. 数据库自动更新令牌信息
Scenario 4: Query Store Tokens
User request:「获取北美站点 A123 店铺的访问令牌」
Steps: 1. 调用 /spApi/storeTokens,传入 sellerId 与 region 2. 返回全部令牌信息(供下游业务调用)
Scenario 5: Prepare Tokens for Any Store Operation (Standard Preparation Workflow)
当用户提出任何涉及卖家后台数据的请求(拉报告、查库存、看订单等),本 skill 负责前置的"选店 → 取令牌"流程,具体业务由相应的下游 skill 接手。
Steps: 1. 列出已授权店铺:调用 /spApi/authorizedStores 2. 让用户选择店铺:如果有多家店铺,请用户明确选哪一家 3. 获取该店铺令牌:调用 /spApi/storeTokens,传入所选店铺的 sellerId 与 region 4. 把 `accessToken` 交给下游 skill(例如 linkfox-amazon-store-report)执行具体操作
Why this workflow is critical:
- 用户可能同时授权了多家不同区域的店铺
- 每家店铺的令牌与权限彼此独立
- 调用必须使用与店铺匹配的令牌,跳过"选店"会导致歧义和错误
Display Rules
1. 先有店铺名再生成授权链接:若用户未提供 sellerName,必须先问,不允许带空值调用 /spApi/authorizeUrl。 2. 只呈现数据:展示授权结果、店铺列表、令牌信息即可,不做业务建议。 3. 安全意识:不要明文显示完整的 accessToken/refreshToken,只展示前 10 个字符等掩码形式。 4. 清晰引导:返回授权链接时,明确告知用户在浏览器中打开并完成授权。 5. 错误说明:授权失败时,基于错误码解释原因并给出建议。 6. 成功确认:授权完成后与用户确认,可选择展示该店铺基本信息。
Important Limitations
- sellerName 必填:
/spApi/authorizeUrl必须传入非空sellerName;脚本与 agent 在调用前务必校验。 - 令牌有效期:
accessToken1 小时过期,需及时刷新。 - 区域专属:每次店铺授权都与具体区域绑定,不同区域需分别授权。
- 用户隔离:用户只能查看/管理自己授权的店铺。
- 回调白名单:系统回调 URL 必须在授权方(紫鸟)处加白名单。
User Expression & Scenario Quick Reference
Applicable — 授权与令牌管理场景:
| User Says | Scenario |
|---|---|
| "授权我的亚马逊店铺" / "Authorize my Amazon store" | 新店铺授权 |
| "看看已授权的亚马逊店铺" / "Show my authorized stores" | 列出已授权店铺 |
| "令牌过期了" / "My token expired" | 刷新令牌 |
| "获取 XXX 店铺的访问令牌" / "Get access token for store" | 查询店铺令牌 |
| "绑定我的亚马逊账号" / "Connect my Amazon seller account" | 新店铺授权 |
Not applicable — 超出本 skill 的业务:
- 拉取亚马逊报告 → 请使用
linkfox-amazon-store-report - 产品 listing 管理、订单处理、库存管理、广告投放 → 由其他 skill 负责
Boundary judgment:
- 本 skill 只负责「授权 + 管店铺 + 管令牌 + 为下游准备 accessToken」。
- 当用户要做具体卖家后台业务(如拉报告)时:
1. 本 skill 执行 Scenario 5 的标准前置流程 2. 随后切换到对应下游 skill 完成业务逻辑
- 不要直接越过本 skill 去调具体 Amazon 开放接口。
Quick Reference
Authorization & Token Management APIs
| API | Path | Purpose | Auth Required |
|---|---|---|---|
| Get Authorization URL | /spApi/authorizeUrl | 生成授权链接(需要 sellerName) | ✅ Yes |
| List Authorized Stores | /spApi/authorizedStores | 查询用户的店铺列表 | ✅ Yes |
| Refresh Token | /spApi/refreshToken | 刷新访问令牌 | ✅ Yes |
| Query Store Tokens | /spApi/storeTokens | 获取某店铺的令牌(给下游 skill 用) | ✅ Yes |
详细请求参数、响应结构、错误码,见 references/api.md。完整授权流程图,见 references/authorization-flow.md。快速上手示例,见 references/quick-start.md。
Feedback:
当出现以下任一情况时,自动通过 Feedback API 上报反馈: 1. 本 skill 描述的功能与实际行为不符 2. skill 的结果与用户意图不符 3. 用户表达了对本 skill 的不满或赞赏 4. 任何你认为可以改进的点
按 references/api.md 中的规范调用 Feedback API,不要打断用户的主流程。
<!-- 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/authorize_url.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:authorize_url.py,authorized_stores.py,refresh_token.py,store_tokens.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 -->
--- For more high-quality, professional cross-border e-commerce skills, visit [LinkFox Skills](https://skill.linkfox.com/).
Amazon 店铺授权 API Reference
本文档描述 授权与店铺/令牌管理 相关的 API。若需经网关代理拉取报告或 Listing 单条查询 等,请参考 linkfox-amazon-store-report、linkfox-amazon-store-listings skill。
Calling Conventions
- Base URL:
https://tool-gateway.linkfox.com(默认;可用STORE_API_BASE_URL或兼容旧名SPAPI_BASE_URL覆盖) - Request Method: 所有接口均为 POST
- Content-Type:
application/json - Authentication: Header
Authorization: <api_key>,API key 读取环境变量LINKFOXAGENT_API_KEY(未配置时,请提示用户向系统管理员获取)
API Endpoints
1. Get Authorization URL
Endpoint: /spApi/authorizeUrl
Request Parameters (JSON):
| Parameter | Type | Required | Description | Example |
|---|---|---|---|---|
| region | string | Yes | 区域代码:NA / EU / FE | "NA" |
| sellerName | string | Yes | 店铺展示名(店铺名)— 必填,非空;用于在已授权店铺列表中识别账号 | "My Store" |
Response:
{
"authorizeUrl": "https://sellercentral.amazon.com/apps/authorize/consent?..."
}说明:授权完成后的回调由 Amazon 直接回调服务端内部接口处理,属于系统内部流程,不作为本 skill 的用户调用接口。
---
2. List Authorized Stores
Endpoint: /spApi/authorizedStores
Request Parameters: 无(使用当前用户上下文)
Response:
{
"stores": [
{
"sellerName": "My Store",
"sellerId": "A1234567890",
"region": "NA"
}
],
"total": 1
}---
3. Refresh Token
Endpoint: /spApi/refreshToken
Request Parameters (JSON):
| Parameter | Type | Required | Description |
|---|---|---|---|
| sellerId | string | Yes | Seller ID |
| region | string | No | 区域代码(精确匹配可选) |
Response:
{
"authRecordId": 123,
"accessToken": "Atza|IwEBIA...",
"refreshToken": "Atzr|IwEBIJ...",
"tokenType": "bearer",
"expiresIn": "3600",
"message": "Token refreshed and updated"
}---
4. Query Store Tokens
Endpoint: /spApi/storeTokens
Request Parameters (JSON):
| Parameter | Type | Required | Description |
|---|---|---|---|
| sellerId | string | Yes | Seller ID |
| region | string | Yes | 区域代码 |
Response:
{
"sellerId": "A1234567890",
"region": "NA",
"authRecordId": 123,
"accessToken": "Atza|IwEBIA...",
"refreshToken": "Atzr|IwEBIJ...",
"tokenType": "bearer",
"expiresIn": "3600"
}返回的 accessToken 可交给下游 skill(如 linkfox-amazon-store-report)用于调用亚马逊开放接口。
---
Error Codes
| errcode | 含义 | 建议动作 |
|---|---|---|
| 200 | 成功 | 正常解析 |
| 1002 | 缺参数或认证失败 | 检查必填参数与认证 |
| 1003 | 第三方服务调用失败 | 稍后重试,检查网络与白名单 |
| 1004 | 授权记录不存在或不属于当前用户 | 核对 sellerId/region 或重新授权 |
Error Response Example:
{
"errcode": 1002,
"errmsg": "Missing required parameter: region"
}---
curl Examples
Get Authorization URL
curl -X POST https://tool-gateway.linkfox.com/spApi/authorizeUrl \
-H "Authorization: $LINKFOXAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"region": "NA", "sellerName": "My Store"}'List Authorized Stores
curl -X POST https://tool-gateway.linkfox.com/spApi/authorizedStores \
-H "Authorization: $LINKFOXAGENT_API_KEY" \
-H "Content-Type: application/json"Refresh Token
curl -X POST https://tool-gateway.linkfox.com/spApi/refreshToken \
-H "Authorization: $LINKFOXAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sellerId": "A1234567890", "region": "NA"}'Query Store Tokens
curl -X POST https://tool-gateway.linkfox.com/spApi/storeTokens \
-H "Authorization: $LINKFOXAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sellerId": "A1234567890", "region": "NA"}'---
Feedback API
本接口与上面的工具 API 是不同 base URL,请勿混用。
- POST
https://skill-api.linkfox.com/api/v1/public/feedback - Content-Type:
application/json
{
"skillName": "linkfox-amazon-store-auth",
"sentiment": "POSITIVE",
"category": "OTHER",
"content": "Authorization flow worked smoothly, user was satisfied."
}Field rules:
skillName: 使用本 skill 的 YAML frontmatternamesentiment:POSITIVE/NEUTRAL/NEGATIVEcategory:BUG/COMPLAINT/SUGGESTION/OTHERcontent: 用户说的话、实际发生了什么、为什么是问题或赞赏
---
Important Notes
1. Token 安全:不要打印完整 accessToken/refreshToken,仅展示前 10 字符掩码。 2. Token 生命周期:accessToken 1 小时过期,使用前检查并按需刷新。 3. 区域专属:同一卖家在不同区域需要分别授权。 4. 用户隔离:所有 API 都强制用户级访问控制。 5. 回调白名单:系统回调 URL 必须在授权提供方(紫鸟)处加白名单。
完整授权流程与实现细节:见 authorization-flow.md。
Amazon Store 授权流程详细说明
本文档提供所有授权相关接口的详细说明,包括请求参数、返回值、错误处理等。
---
1. 获取授权URL
接口信息
- 路径:
/spApi/authorizeUrl - 方法: POST (RouteMapping)
- 鉴权: 需要(从 Token 中获取 userId)
- 实现:
SpApiController.java:52
请求参数 (SpApiAuthorizeUrlReq)
| 参数 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| region | String | ✅ 是 | 区域代码:NA/EU/FE | "NA" |
| sellerName | String | ✅ 是(必填) | 店铺名 / 卖家展示名称,用于在已授权店铺列表中区分账号;调用前必须向用户确认并传入非空字符串,不可省略 | "My Amazon Store" |
| central | String | ❌ 否 | 中心站点 | - |
| marketplace | String | ❌ 否 | 市场代码 | - |
返回结果 (SpApiAuthorizeUrlVo)
{
"authorizeUrl": "https://sellercentral.amazon.com/..."
}| 字段 | 类型 | 说明 |
|---|---|---|
| authorizeUrl | String | 亚马逊授权链接,用户需在浏览器中打开 |
业务逻辑
1. 校验 region 参数(必须为 NA/EU/FE);本 Skill 约定:sellerName 须为非空字符串(与用户在 LinkFox 侧展示、区分店铺一致),AI/脚本在调用前应向用户确认店铺名。 2. 构建 state 参数:
- 包含 gateway.url + /spApi/oauth/callback
- 附加 userId, region, sellerName
3. 调用紫鸟代理接口 /developer-proxy/v1/authorize/url 4. 返回授权链接给用户
错误码
| 错误码 | 说明 | 处理建议 |
|---|---|---|
| 1002 | 缺少 region 参数 | 必须提供 region (NA/EU/FE) |
| 1003 | 获取授权地址失败 | 检查网络连接和白名单配置,稍后重试 |
使用示例
请求:
{
"region": "NA",
"sellerName": "MyStore"
}响应:
{
"authorizeUrl": "https://sellercentral.amazon.com/apps/authorize/consent?application_id=xxx&state=xxx"
}后续操作: 用户在浏览器中打开 authorizeUrl,在亚马逊页面完成授权后,会自动重定向到回调地址。
---
2. 授权回调处理(服务端内部)
接口信息
- 路径: 服务端内部回调接口(不对客户端/Agent暴露)
- 方法: POST (RouteMapping)
- 鉴权: 不需要(auth=false)
- 实现:
SpApiController.java:111
请求参数 (SpApiAuthorizeCallbackReq)
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| userId | String | ✅ 是 | 用户ID(从 state 中解析) |
| sellingPartnerId | String | ✅ 是 | 亚马逊卖家ID |
| accessToken | String | ✅ 是 | 访问令牌 |
| refreshToken | String | ✅ 是 | 刷新令牌 |
| tokenType | String | ❌ 否 | 令牌类型(默认 bearer) |
| expiresIn | String | ❌ 否 | 过期时间(秒) |
| region | String | ✅ 是 | 区域代码 |
| sellerName | String | ❌ 否 | 卖家名称 |
| mwsAuthToken | String | ❌ 否 | MWS 授权令牌 |
返回结果 (SpApiAuthorizeCallbackVo)
{
"saved": true,
"authRecordId": 123,
"bindUserId": 456,
"message": "已新增授权"
}| 字段 | 类型 | 说明 |
|---|---|---|
| saved | Boolean | 是否保存成功 |
| authRecordId | Long | 授权记录ID(sp_api_amazon_auth 表) |
| bindUserId | Long | 绑定记录ID(sp_api_bind_user 表) |
| message | String | 处理结果消息 |
业务逻辑
1. 检查是否已存在同一店铺的授权(sellingPartnerId + region) 2. 如果存在,更新原有授权记录;否则新增 3. 创建或更新用户与授权的绑定关系 4. 返回保存结果
注意事项
- 此接口无需鉴权,因为是亚马逊重定向回调
- userId 从 state 参数中解析,必须在获取授权URL时正确设置
- 同一店铺的授权会更新而非重复创建
---
3. 查看已授权店铺列表
接口信息
- 路径:
/spApi/authorizedStores - 方法: POST (RouteMapping)
- 鉴权: 需要(从 Token 中获取 userId)
- 实现:
SpApiController.java:126
请求参数
无(从 Token 中自动获取当前用户 userId)
返回结果 (SpApiAuthorizedStoresVo)
{
"stores": [
{
"sellerName": "My Store",
"sellerId": "A1234567890",
"region": "NA"
},
{
"sellerName": "EU Store",
"sellerId": "A9876543210",
"region": "EU"
}
],
"total": 2
}| 字段 | 类型 | 说明 |
|---|---|---|
| stores | Array | 店铺列表 |
| stores[].sellerName | String | 卖家名称 |
| stores[].sellerId | String | 卖家ID |
| stores[].region | String | 区域代码 |
| total | Integer | 店铺总数 |
业务逻辑
1. 根据 gatewayUserId 查询 sp_api_bind_user 表 2. 获取所有关联的 amazonAuthId 3. 查询对应的授权记录 4. 去重并按 sellerId 和 region 排序 5. 返回店铺列表
错误码
| 错误码 | 说明 | 处理建议 |
|---|---|---|
| 1002 | 无法识别当前用户 | 请重新登录 |
---
4. 刷新访问令牌
接口信息
- 路径:
/spApi/refreshToken - 方法: POST (RouteMapping)
- 鉴权: 需要(从 Token 中获取 userId)
- 实现:
SpApiController.java:134
请求参数 (SpApiRefreshTokenReq)
| 参数 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| sellerId | String | ✅ 是 | 卖家ID | "A1234567890" |
| region | String | ❌ 否 | 区域代码(用于精确匹配) | "NA" |
返回结果 (SpApiRefreshTokenVo)
{
"authRecordId": 123,
"accessToken": "Atza|IwEBIA...",
"refreshToken": "Atzr|IwEBIJ...",
"tokenType": "bearer",
"expiresIn": "3600",
"message": "刷新成功并已更新数据库"
}| 字段 | 类型 | 说明 |
|---|---|---|
| authRecordId | Long | 授权记录ID |
| accessToken | String | 新的访问令牌 |
| refreshToken | String | 新的刷新令牌(可能更新) |
| tokenType | String | 令牌类型 |
| expiresIn | String | 过期时间(秒) |
| message | String | 处理结果 |
业务逻辑
1. 根据 sellerId + region 查询授权记录 2. 校验该授权是否属于当前用户 3. 调用紫鸟代理接口 /developer-proxy/{region}/auth/o2/token 4. 使用 refresh_token 换取新的 access_token 5. 更新数据库中的令牌信息 6. 返回新令牌
错误码
| 错误码 | 说明 | 处理建议 |
|---|---|---|
| 1002 | 请指定 sellerId | 必须提供卖家ID |
| 1004 | 未找到授权记录或不属于当前用户 | 检查 sellerId 是否正确,或重新授权 |
| 1004 | 缺少 refresh_token | 授权记录异常,需重新授权 |
| 1003 | 刷新令牌请求失败 | 检查网络连接,稍后重试 |
注意事项
- refresh_token 可能在刷新时更新,需保存新的 refresh_token
- 如果 region 未提供,会匹配该 sellerId 的第一条记录
---
5. 查询店铺令牌
接口信息
- 路径:
/spApi/storeTokens - 方法: POST (RouteMapping)
- 鉴权: 需要(从 Token 中获取 userId)
- 实现:
SpApiController.java:142
请求参数 (SpApiStoreTokensReq)
| 参数 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| sellerId | String | ✅ 是 | 卖家ID | "A1234567890" |
| region | String | ✅ 是 | 区域代码 | "NA" |
返回结果 (SpApiStoreTokensVo)
{
"sellerId": "A1234567890",
"region": "NA",
"authRecordId": 123,
"accessToken": "Atza|IwEBIA...",
"refreshToken": "Atzr|IwEBIJ...",
"tokenType": "bearer",
"expiresIn": "3600"
}| 字段 | 类型 | 说明 |
|---|---|---|
| sellerId | String | 卖家ID |
| region | String | 区域代码 |
| authRecordId | Long | 授权记录ID |
| accessToken | String | 访问令牌 |
| refreshToken | String | 刷新令牌 |
| tokenType | String | 令牌类型 |
| expiresIn | String | 过期时间(秒) |
业务逻辑
1. 根据 sellerId + region 查询授权记录 2. 校验该授权是否属于当前用户 3. 直接从数据库读取令牌信息(不调用刷新) 4. 返回令牌数据
错误码
| 错误码 | 说明 | 处理建议 |
|---|---|---|
| 1002 | 请指定 sellerId 或 region | 必须同时提供卖家ID和区域 |
| 1004 | 未找到授权记录或不属于当前用户 | 检查参数或重新授权 |
使用场景
- 在调用亚马逊卖家开放接口前获取访问令牌
- 检查令牌是否即将过期(根据 expiresIn)
- 如果令牌过期,调用 refreshToken 接口更新
---
区域与站点映射
北美 (NA)
- 美国: amazon.com
- 加拿大: amazon.ca
- 墨西哥: amazon.com.mx
欧洲 (EU)
- 英国: amazon.co.uk
- 德国: amazon.de
- 法国: amazon.fr
- 意大利: amazon.it
- 西班牙: amazon.es
- 荷兰: amazon.nl
- 瑞典: amazon.se
- 波兰: amazon.pl
远东 (FE)
- 日本: amazon.co.jp
- 澳大利亚: amazon.com.au
- 新加坡: amazon.sg
---
数据库表结构
sp_api_amazon_auth (授权信息表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | 主键 |
| region | String | 区域代码 (NA/EU/FE) |
| accessToken | String | 访问令牌 |
| refreshToken | String | 刷新令牌 |
| tokenType | String | 令牌类型 |
| expiresIn | String | 过期时间(秒) |
| sellingPartnerId | String | 卖家ID |
| sellerName | String | 卖家名称 |
| mwsAuthToken | String | MWS 授权令牌 |
| createDate | Date | 创建时间 |
| createTime | Long | 创建时间戳 |
| lastUpdateDate | Date | 更新时间 |
| lastUpdateTime | Long | 更新时间戳 |
sp_api_bind_user (用户绑定表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | 主键 |
| gatewayUserId | String | 网关用户ID |
| amazonAuthId | Long | 授权记录ID(外键) |
| createDate | Date | 创建时间 |
| createTime | Long | 创建时间戳 |
| lastUpdateDate | Date | 更新时间 |
| lastUpdateTime | Long | 更新时间戳 |
---
完整授权示例
步骤 1: 获取授权链接
请求:
POST /spApi/authorizeUrl
Headers: Authorization: Bearer <token>
Body: {
"region": "NA",
"sellerName": "MyStore"
}响应:
{
"authorizeUrl": "https://sellercentral.amazon.com/apps/authorize/consent?..."
}步骤 2: 用户授权
用户在浏览器中打开 authorizeUrl,登录亚马逊卖家中心并同意授权。
步骤 3: 自动回调
亚马逊重定向到:
https://<gateway.url>/spApi/oauth/callback?
userId=<userId>&
region=NA&
sellerName=MyStore&
selling_partner_id=A1234567890&
access_token=Atza|...&
refresh_token=Atzr|...&
token_type=bearer&
expires_in=3600系统自动保存授权信息。
步骤 4: 查看授权结果
请求:
POST /spApi/authorizedStores
Headers: Authorization: Bearer <token>响应:
{
"stores": [
{
"sellerName": "MyStore",
"sellerId": "A1234567890",
"region": "NA"
}
],
"total": 1
}步骤 5: 使用令牌调用卖家开放接口
请求:
POST /spApi/storeTokens
Headers: Authorization: Bearer <token>
Body: {
"sellerId": "A1234567890",
"region": "NA"
}响应:
{
"accessToken": "Atza|...",
"refreshToken": "Atzr|...",
"expiresIn": "3600"
}使用 accessToken 作为 x-amz-access-token header 调用亚马逊卖家开放接口。
---
故障排查
问题 1: 授权链接无法访问
可能原因:
- 网络连接问题
- 紫鸟代理服务异常
- 白名单配置错误
解决方案: 1. 检查网络连接 2. 确认白名单配置 3. 稍后重试或联系技术支持
问题 2: 回调未保存授权信息
可能原因:
- state 参数中缺少 userId
- 数据库连接异常
解决方案: 1. 确认获取授权URL时正确传入用户信息 2. 检查数据库连接 3. 查看服务日志
问题 3: 刷新令牌失败
可能原因:
- refresh_token 已过期或失效
- 紫鸟代理服务异常
解决方案: 1. 如果 refresh_token 失效,需重新授权 2. 检查紫鸟服务状态 3. 确认白名单配置
问题 4: 查询令牌返回 1004 错误
可能原因:
- sellerId 或 region 错误
- 授权记录不属于当前用户
解决方案: 1. 调用 /spApi/authorizedStores 确认店铺信息 2. 确认 sellerId 和 region 正确 3. 如果确实未授权,需先完成授权流程
---
安全最佳实践
1. 令牌存储:
- 令牌存储在数据库中,不暴露给前端
- 仅通过后端接口访问令牌
2. 访问控制:
- 所有接口都进行用户鉴权
- 用户只能访问自己授权的店铺
3. 令牌刷新:
- 定期检查令牌是否即将过期
- 自动刷新即将过期的令牌
4. 日志记录:
- 记录所有授权操作
- 记录令牌刷新操作
- 不记录令牌明文内容
5. 错误处理:
- 不在错误消息中暴露敏感信息
- 提供清晰的错误码和处理建议
Amazon Store 授权快速开始指南
本指南帮助你快速上手使用亚马逊店铺授权功能。
前置条件
1. 已部署的服务:
- linkfox-agent-ecom-plat 服务已启动
- 紫鸟代理服务可访问
- 数据库已正确配置
2. 已配置的环境:
- 回调地址已添加到紫鸟白名单
- gateway.url 配置正确
3. 用户认证:
- 用户已登录并获取 Token
5分钟快速授权
重要:店铺名(`sellerName`)必填
调用/spApi/authorizeUrl时必须传入非空的sellerName,用于在系统中标识该授权店铺(多店铺时便于区分)。若用户未提供,请先询问用户填写后再请求授权链接。脚本authorize_url.py会在本地校验该字段。
第一步:获取授权链接
调用接口:
POST /spApi/authorizeUrl
Content-Type: application/json
Authorization: Bearer <your-token>
{
"region": "NA",
"sellerName": "我的店铺"
}预期响应:
{
"authorizeUrl": "https://sellercentral.amazon.com/apps/authorize/consent?..."
}操作: 复制 authorizeUrl 的值
第二步:浏览器授权
1. 在浏览器中打开上一步获取的 authorizeUrl 2. 使用亚马逊卖家账号登录 3. 查看并同意授权请求 4. 点击"确认"或"Authorize"按钮 5. 等待页面跳转(自动完成授权保存)
第三步:验证授权成功
调用接口:
POST /spApi/authorizedStores
Content-Type: application/json
Authorization: Bearer <your-token>预期响应:
{
"stores": [
{
"sellerName": "我的店铺",
"sellerId": "A1234567890",
"region": "NA"
}
],
"total": 1
}如果看到店铺信息,说明授权成功!
使用授权令牌
获取访问令牌
调用接口:
POST /spApi/storeTokens
Content-Type: application/json
Authorization: Bearer <your-token>
{
"sellerId": "A1234567890",
"region": "NA"
}预期响应:
{
"sellerId": "A1234567890",
"region": "NA",
"accessToken": "Atza|IwEBIA...",
"refreshToken": "Atzr|IwEBIJ...",
"tokenType": "bearer",
"expiresIn": "3600"
}使用访问令牌调用卖家开放接口
使用返回的 accessToken 作为请求头:
GET https://<endpoint>/orders/v0/orders
x-amz-access-token: Atza|IwEBIA...令牌管理
令牌过期时间
- accessToken: 通常 1 小时(3600秒)
- refreshToken: 长期有效,用于刷新 accessToken
检查令牌是否即将过期
从 /spApi/storeTokens 响应中获取 expiresIn 值:
- 如果小于 300 秒(5分钟),建议立即刷新
- 如果大于 300 秒,可以继续使用
刷新过期令牌
调用接口:
POST /spApi/refreshToken
Content-Type: application/json
Authorization: Bearer <your-token>
{
"sellerId": "A1234567890",
"region": "NA"
}预期响应:
{
"authRecordId": 123,
"accessToken": "Atza|IwEBIA...(新令牌)",
"refreshToken": "Atzr|IwEBIJ...",
"tokenType": "bearer",
"expiresIn": "3600",
"message": "刷新成功并已更新数据库"
}刷新后,使用新的 accessToken 进行后续 API 调用。
多店铺管理
授权第二个店铺
重复授权流程,但使用不同的区域或账号:
POST /spApi/authorizeUrl
{
"region": "EU",
"sellerName": "欧洲店铺"
}查看所有授权店铺
POST /spApi/authorizedStores响应会包含所有已授权的店铺:
{
"stores": [
{
"sellerName": "我的店铺",
"sellerId": "A1234567890",
"region": "NA"
},
{
"sellerName": "欧洲店铺",
"sellerId": "A9876543210",
"region": "EU"
}
],
"total": 2
}为不同店铺获取令牌
只需指定不同的 sellerId 和 region:
POST /spApi/storeTokens
{
"sellerId": "A9876543210",
"region": "EU"
}常见场景
场景 1: 定时任务调用卖家开放接口
1. 从数据库或缓存中读取 accessToken 2. 检查是否过期(根据上次更新时间 + expiresIn) 3. 如果过期,调用 /spApi/refreshToken 刷新 4. 使用新令牌调用卖家开放接口
场景 2: 多店铺数据同步
1. 调用 /spApi/authorizedStores 获取所有店铺 2. 遍历店铺列表 3. 为每个店铺获取令牌 (/spApi/storeTokens) 4. 并行调用卖家开放接口获取数据
场景 3: 用户重新授权
如果用户在亚马逊卖家中心撤销了授权:
1. 老令牌会失效 2. 调用卖家开放接口 会返回 401 Unauthorized 3. 需要用户重新授权(重复获取授权链接的流程) 4. 系统会自动更新数据库中的令牌
故障排查
问题:获取授权链接失败(错误码 1003)
可能原因: 网络问题或白名单配置错误
解决方法: 1. 检查网络连接到紫鸟代理服务 2. 确认回调地址已添加到白名单 3. 查看服务日志获取详细错误信息
问题:授权完成但未保存(查询不到店铺)
可能原因: 回调参数缺失或数据库异常
解决方法: 1. 检查浏览器回调 URL 是否包含所有参数 2. 查看服务日志,确认回调是否被触发 3. 检查数据库连接和表结构
问题:刷新令牌失败(错误码 1004)
可能原因: refresh_token 已失效
解决方法: 1. refresh_token 一旦失效,无法恢复 2. 需要用户重新完成授权流程 3. 建议定期刷新令牌,避免长时间不使用导致失效
问题:调用卖家开放接口 返回 401
可能原因: accessToken 过期或无效
解决方法: 1. 调用 /spApi/refreshToken 刷新令牌 2. 如果刷新失败,需要重新授权 3. 使用新令牌重试 API 调用
最佳实践
1. 令牌缓存策略
获取令牌时:
↓
检查缓存是否存在且未过期
↓
如果是,直接使用缓存的令牌
↓
如果否,从数据库读取并检查过期时间
↓
如果即将过期(< 5分钟),先刷新
↓
将新令牌写入缓存2. 错误重试机制
调用卖家开放接口
↓
如果返回 401
↓
刷新令牌
↓
重试 API 调用(最多1次)
↓
如果仍失败,返回错误3. 批量操作优化
获取所有店铺列表
↓
批量获取所有店铺的令牌
↓
并行调用卖家开放接口(控制并发数)
↓
汇总结果4. 安全建议
- ✅ 令牌仅存储在后端,不传递给前端
- ✅ 使用 HTTPS 传输令牌
- ✅ 定期检查并刷新令牌
- ✅ 记录所有授权操作日志
- ❌ 不在日志中记录完整令牌
- ❌ 不在前端 JavaScript 中存储令牌
下一步
- 查看 完整接口文档 了解所有接口的详细说明
- 查看 SKILL.md 了解 skill 的完整功能
- 参考后端工程中店铺网关相关 Controller 实现(包名与类名以你们仓库为准)
技术支持
如遇到问题,请: 1. 查看服务日志 2. 参考故障排查章节 3. 联系技术团队
#!/usr/bin/env python3
"""
Amazon Store Authorization URL - LinkFox Skill
Calls the /spApi/authorizeUrl endpoint to generate authorization URL
Usage:
python authorize_url.py '{"region": "NA", "sellerName": "My Store"}'
"""
import json
import os
import sys
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError
API_BASE_URL = os.environ.get("STORE_API_BASE_URL") or os.environ.get(
"SPAPI_BASE_URL", "https://tool-gateway.linkfox.com"
)
API_ENDPOINT = f"{API_BASE_URL}/spApi/authorizeUrl"
def get_api_key():
"""Retrieve the API key from environment, with a friendly prompt if missing."""
key = os.environ.get("LINKFOXAGENT_API_KEY")
if not key:
print(
"API Key not configured. Please set the environment variable:\n"
" export LINKFOXAGENT_API_KEY=your-key-here",
file=sys.stderr,
)
sys.exit(1)
return key
def call_api(params: dict) -> dict:
"""Call the authorization URL API."""
api_key = get_api_key()
data = json.dumps(params).encode("utf-8")
req = Request(
API_ENDPOINT,
data=data,
headers={
"Authorization": api_key,
"Content-Type": "application/json",
"User-Agent": "LinkFox-Skill/1.0",
},
method="POST",
)
try:
with urlopen(req, timeout=30) 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 main():
if len(sys.argv) < 2:
print("Usage: authorize_url.py '<JSON parameters>'", file=sys.stderr)
print(
'Example: authorize_url.py \'{"region": "NA", "sellerName": "My Store"}\'',
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid parameter format: {e}", file=sys.stderr)
sys.exit(1)
# Validate required fields
if "region" not in params:
print("Error: 'region' parameter is required (NA/EU/FE)", file=sys.stderr)
sys.exit(1)
if "sellerName" not in params:
print(
"Error: 'sellerName' (店铺名) is required — 授权前必须填写可识别的店铺名称,"
"用于在已授权店铺列表中区分账号,请勿留空或省略。",
file=sys.stderr,
)
sys.exit(1)
seller_name = params["sellerName"]
if not isinstance(seller_name, str) or not seller_name.strip():
print(
"Error: 'sellerName' (店铺名) must be a non-empty string — "
"请提供有意义的店铺名称(不能与空白相同)。",
file=sys.stderr,
)
sys.exit(1)
params["sellerName"] = seller_name.strip()
result = call_api(params)
print(json.dumps(result, indent=2, ensure_ascii=False))
# If successful, print helpful instructions
if "authorizeUrl" in result:
print("\n✓ Authorization URL generated successfully!", file=sys.stderr)
print(
f"店铺名已记录为: {params['sellerName']}(请确认与亚马逊后台展示名称一致,便于后续识别)。",
file=sys.stderr,
)
print("Please open the following URL in your browser to authorize:", file=sys.stderr)
print(f"\n {result['authorizeUrl']}\n", file=sys.stderr)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Amazon Authorized Stores List - LinkFox Skill
Calls the /spApi/authorizedStores endpoint to list all authorized stores
Usage:
python authorized_stores.py
"""
import json
import os
import sys
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError
API_BASE_URL = os.environ.get("STORE_API_BASE_URL") or os.environ.get(
"SPAPI_BASE_URL", "https://tool-gateway.linkfox.com"
)
API_ENDPOINT = f"{API_BASE_URL}/spApi/authorizedStores"
def get_api_key():
"""Retrieve the API key from environment, with a friendly prompt if missing."""
key = os.environ.get("LINKFOXAGENT_API_KEY")
if not key:
print(
"API Key not configured. Please set the environment variable:\n"
" export LINKFOXAGENT_API_KEY=your-key-here",
file=sys.stderr,
)
sys.exit(1)
return key
def call_api() -> dict:
"""Call the authorized stores API."""
api_key = get_api_key()
req = Request(
API_ENDPOINT,
data=b"{}",
headers={
"Authorization": api_key,
"Content-Type": "application/json",
"User-Agent": "LinkFox-Skill/1.0",
},
method="POST",
)
try:
with urlopen(req, timeout=30) 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 main():
result = call_api()
print(json.dumps(result, indent=2, ensure_ascii=False))
# If successful, print summary
if "stores" in result:
stores = result.get("stores", [])
total = result.get("total", 0)
print(f"\n✓ Found {total} authorized store(s):", file=sys.stderr)
for store in stores:
print(f" - {store.get('sellerName', 'N/A')} ({store.get('sellerId')}) - {store.get('region')}", file=sys.stderr)
if __name__ == "__main__":
main()
Amazon Store Auth Scripts Usage Guide
本目录包含 授权与店铺/令牌管理 相关的 Python 脚本。若需要拉取报告,请使用 linkfox-amazon-store-report skill。
Prerequisites
- Python 3.6 或更高
- 已设置
LINKFOXAGENT_API_KEY环境变量 - 可访问 LinkFox 后端 API(默认
https://tool-gateway.linkfox.com)
export LINKFOXAGENT_API_KEY="your-api-key-here"Available Scripts
1. authorize_url.py
为新店铺生成授权 URL。
`sellerName`(店铺名)必填:必须为非空字符串。脚本会在缺失或为空白时直接退出并报错——调用前请先向用户询问一个可识别的店铺名。
python authorize_url.py '{"region": "NA", "sellerName": "My Store"}'2. authorized_stores.py
列出当前用户已授权的所有亚马逊店铺。
python authorized_stores.py3. refresh_token.py
刷新某店铺的 accessToken。
python refresh_token.py '{"sellerId": "A1234567890", "region": "NA"}'4. store_tokens.py
获取某店铺的访问令牌。下游 skill(如 linkfox-amazon-store-report)会调用它以拿到 accessToken。
python store_tokens.py '{"sellerId": "A1234567890", "region": "NA"}'Environment Variables
| Variable | Description | Default |
|---|---|---|
| LINKFOXAGENT_API_KEY | API 鉴权 key | 必需 |
| STORE_API_BASE_URL / SPAPI_BASE_URL | 后端网关 base URL(优先读前者) | https://tool-gateway.linkfox.com |
Error Codes
0:成功1:缺少 API key、参数错误、网络/HTTP/权限错误
Troubleshooting
API Key 未配置
export LINKFOXAGENT_API_KEY="your-key-here"Connection Refused / 网络错误
- 确认
https://tool-gateway.linkfox.com能从你的网络访问(或设置STORE_API_BASE_URL/SPAPI_BASE_URL指向其他网关) - 检查防火墙、代理设置
403 Unauthorized
- 店铺可能缺少必要的亚马逊接口权限
- 用更完整的权限集合重新授权
查询令牌返回 1004
- 核对 sellerId 与 region
- 确认该店铺已完成授权
Further Documentation
- API Reference:
../references/api.md - 授权流程详解:
../references/authorization-flow.md - 快速上手:
../references/quick-start.md - Skill 文档:
../SKILL.md
#!/usr/bin/env python3
"""
Amazon Store Token Refresh - LinkFox Skill
Calls the /spApi/refreshToken endpoint to refresh access token
Usage:
python refresh_token.py '{"sellerId": "A1234567890", "region": "NA"}'
"""
import json
import os
import sys
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError
API_BASE_URL = os.environ.get("STORE_API_BASE_URL") or os.environ.get(
"SPAPI_BASE_URL", "https://tool-gateway.linkfox.com"
)
API_ENDPOINT = f"{API_BASE_URL}/spApi/refreshToken"
def get_api_key():
"""Retrieve the API key from environment, with a friendly prompt if missing."""
key = os.environ.get("LINKFOXAGENT_API_KEY")
if not key:
print(
"API Key not configured. Please set the environment variable:\n"
" export LINKFOXAGENT_API_KEY=your-key-here",
file=sys.stderr,
)
sys.exit(1)
return key
def call_api(params: dict) -> dict:
"""Call the refresh token API."""
api_key = get_api_key()
data = json.dumps(params).encode("utf-8")
req = Request(
API_ENDPOINT,
data=data,
headers={
"Authorization": api_key,
"Content-Type": "application/json",
"User-Agent": "LinkFox-Skill/1.0",
},
method="POST",
)
try:
with urlopen(req, timeout=30) 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 main():
if len(sys.argv) < 2:
print("Usage: refresh_token.py '<JSON parameters>'", file=sys.stderr)
print(
'Example: refresh_token.py \'{"sellerId": "A1234567890", "region": "NA"}\'',
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid parameter format: {e}", file=sys.stderr)
sys.exit(1)
# Validate required fields
if "sellerId" not in params:
print("Error: 'sellerId' parameter is required", file=sys.stderr)
sys.exit(1)
result = call_api(params)
# Mask tokens in output for security
if "accessToken" in result:
result["accessToken"] = result["accessToken"][:10] + "..." if len(result["accessToken"]) > 10 else result["accessToken"]
if "refreshToken" in result:
result["refreshToken"] = result["refreshToken"][:10] + "..." if len(result["refreshToken"]) > 10 else result["refreshToken"]
print(json.dumps(result, indent=2, ensure_ascii=False))
# If successful, print confirmation
if "message" in result:
print(f"\n✓ {result['message']}", file=sys.stderr)
print("Note: Tokens have been masked for security. Full tokens are stored in database.", file=sys.stderr)
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 Tokens Query - LinkFox Skill
Calls the /spApi/storeTokens endpoint to get store tokens
Usage:
python store_tokens.py '{"sellerId": "A1234567890", "region": "NA"}'
"""
import json
import os
import sys
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError
API_BASE_URL = os.environ.get("STORE_API_BASE_URL") or os.environ.get(
"SPAPI_BASE_URL", "https://tool-gateway.linkfox.com"
)
API_ENDPOINT = f"{API_BASE_URL}/spApi/storeTokens"
def get_api_key():
"""Retrieve the API key from environment, with a friendly prompt if missing."""
key = os.environ.get("LINKFOXAGENT_API_KEY")
if not key:
print(
"API Key not configured. Please set the environment variable:\n"
" export LINKFOXAGENT_API_KEY=your-key-here",
file=sys.stderr,
)
sys.exit(1)
return key
def call_api(params: dict) -> dict:
"""Call the store tokens API."""
api_key = get_api_key()
data = json.dumps(params).encode("utf-8")
req = Request(
API_ENDPOINT,
data=data,
headers={
"Authorization": api_key,
"Content-Type": "application/json",
"User-Agent": "LinkFox-Skill/1.0",
},
method="POST",
)
try:
with urlopen(req, timeout=30) 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 main():
if len(sys.argv) < 2:
print("Usage: store_tokens.py '<JSON parameters>'", file=sys.stderr)
print(
'Example: store_tokens.py \'{"sellerId": "A1234567890", "region": "NA"}\'',
file=sys.stderr,
)
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(f"Invalid parameter format: {e}", file=sys.stderr)
sys.exit(1)
# Validate required fields
if "sellerId" not in params or "region" not in params:
print("Error: Both 'sellerId' and 'region' parameters are required", file=sys.stderr)
sys.exit(1)
result = call_api(params)
# Mask tokens in output for security
if "accessToken" in result:
result["accessToken"] = result["accessToken"][:10] + "..." if len(result["accessToken"]) > 10 else result["accessToken"]
if "refreshToken" in result:
result["refreshToken"] = result["refreshToken"][:10] + "..." if len(result["refreshToken"]) > 10 else result["refreshToken"]
print(json.dumps(result, indent=2, ensure_ascii=False))
# If successful, print helpful info
if "expiresIn" in result:
expires_in = result.get("expiresIn", "0")
print(f"\n✓ Tokens retrieved successfully", file=sys.stderr)
print(f"Token expires in: {expires_in} seconds", file=sys.stderr)
print("Note: Tokens have been masked for security.", file=sys.stderr)
if __name__ == "__main__":
main()