Install
openclaw skills install amazon-competitor-keepa-monitor亚马逊竞品全维运营监控报告生成器 — 通过 SellerSprite Keepa MCP 拉取多ASIN历史数据,生成 Chart.js 交互式 HTML 报告 + Excel 明细表
openclaw skills install amazon-competitor-keepa-monitor通过 SellerSprite Keepa MCP 批量拉取多个 ASIN 的历史数据(价格、BSR、评论、评分),生成带筛选器的交互式 HTML 报告和 Excel 数据表,通过 Dinzee 交付公网链接。
amazon-competitor-keepa-monitor/
├── SKILL.md # 本文件
├── templates/
│ └── report.html # Chart.js 交互式 HTML 报告模版
└── scripts/
└── generate_excel.py # Excel 工作簿生成脚本
templates/report.html)Chart.js 交互式报告,配色 #0f3460 + #e94560。Python 生成时用 f-string 替换以下占位符:
| 占位符 | 说明 | 示例 |
|---|---|---|
{REPORT_TITLE} | 报告标题(品类名) | iPhone 15 手机壳 |
{DATE_RANGE} | 数据周期 | 2025-11-21 ~ 2026-05-20 |
{GENERATE_TIME} | 生成时间 | 2026-05-21 22:03 |
{COUNT} | 竞品数量 | 8 |
{ASIN_CARDS_HTML} | ASIN 卡片 HTML(见下方生成逻辑) | 见 Step 5 |
{DATA_JSON} | JS DATA 对象 JSON | 见 Step 5 |
{MONTHLY_JSON} | JS MONTHLY 对象 JSON | 见 Step 5 |
{ASIN_LIST_JSON} | ASIN 数组 JSON | ["B0XX1","B0XX2"] |
{VARIANT_ANALYSIS_JSON} | 变体分析 HTML 字符串(JSON-encoded) | 见 Step 5 |
{COUNTER_STRATEGY_JSON} | 反制策略 HTML 字符串(JSON-encoded) | 见 Step 5 |
⚠️ 重要: {DATA_JSON} 和 {MONTHLY_JSON} 体积巨大(~100KB),需要用 json.dumps(data, separators=(',',':')) 紧凑格式,不要加空格。
scripts/generate_excel.py)python scripts/generate_excel.py \
--keepa /tmp/keepa_data.json \
--details /tmp/asin_details.json \
--output /tmp/competitive_report.xlsx
输出工作簿包含:
keepa_info): 历史价格、BSR、评论、评分、BuyBox、Deal价asin_detail): 基础信息(标题、品牌、上架时间、类目)import json, urllib.request
PROXY = {'https': 'http://127.0.0.1:1087', 'http': 'http://127.0.0.1:1087'}
# ⚠️ Config 位置:/Users/dino/.openclaw/workspace/config/mcporter.json (不是 ~/.hermes/mcporter.json)
# 也可用 `mcporter config list` 直接查看 sellersprite-mcp 的 URL
SS_URL = 'https://mcp.sellersprite.com/mcp?secret-key=<KEY>' # 从 mcporter config 获取
opener = urllib.request.build_opener(urllib.request.ProxyHandler(PROXY))
def ss_init():
# 1. initialize
payload = json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'initialize',
'params': {'protocolVersion': '2024-11-05', 'capabilities': {},
'clientInfo': {'name': 'agent', 'version': '1.0'}}}).encode()
req = urllib.request.Request(SS_URL, data=payload, headers={
'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream'})
opener.open(req, timeout=30)
# 2. notifications/initialized (必须!)
payload_n = json.dumps({'jsonrpc': '2.0', 'method': 'notifications/initialized', 'params': {}}).encode()
req_n = urllib.request.Request(SS_URL, data=payload_n, headers={
'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream'})
opener.open(req_n, timeout=10)
def ss_call(tool, args, call_id=10, timeout=180):
payload = json.dumps({'jsonrpc': '2.0', 'id': call_id, 'method': 'tools/call',
'params': {'name': tool, 'arguments': args}}).encode()
req = urllib.request.Request(SS_URL, data=payload, headers={
'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream'})
resp = opener.open(req, timeout=timeout)
raw = json.loads(resp.read())
text = raw['result']['content'][0]['text']
parsed = json.loads(text)
return parsed.get('data', parsed)
⚠️ 关键坑点:
http://127.0.0.1:1087notifications/initialized 通知keepa_info 返回 ~150KB,timeout 设 180smcporter call 会截断 >64KB 的响应,必须用直接 HTTP 方式details = {}
for asin in ASINS:
details[asin] = ss_call('asin_detail', {'marketplace': 'US', 'asin': asin})
asin_detail 返回 variationList(子ASIN列表)和 variations(总数),可用于竞品SKU矩阵分析:
def analyze_variants(detail):
"""从 asin_detail 提取颜色/型号变体分布"""
colors, sizes = set(), set()
for v in detail.get('variationList', []):
attr = v.get('attribute', '')
for p in attr.split(' | '):
if p.startswith('Color:'): colors.add(p.replace('Color: ', '').strip())
elif p.startswith('Size:'): sizes.add(p.replace('Size: ', '').strip())
return {
'total_variants': detail.get('variations', 0),
'colors': sorted(colors),
'sizes': sorted(sizes),
'color_count': len(colors),
'size_count': len(sizes),
}
keepa = {}
for asin in ASINS:
keepa[asin] = ss_call('keepa_info', {'marketplace': 'US', 'asin': asin}, timeout=180)
def process_timeseries(items, start_ms, end_ms):
daily = {}
for item in items:
ts = item.get('timePoint', 0)
val = item.get('value')
if val is None or val == -1 or ts < start_ms or ts > end_ms:
continue
date_str = datetime.datetime.fromtimestamp(ts/1000, tz=datetime.timezone.utc).strftime('%Y-%m-%d')
if date_str not in daily or ts > daily[date_str][0]:
daily[date_str] = (ts, val)
return {k: v[1] for k, v in daily.items()}
| 字段 | 说明 | 格式 |
|---|---|---|
price | 历史价格 | [{timePoint: ms, value: float}] |
bsr | 大类排名 | [{timePoint: ms, value: int}] |
subSalesRank | 小类排名 | [{nodeId, node, ranks: [{timePoint, value}]}] |
reviews | 评论数 | [{timePoint: ms, value: int}] |
rating | 评分 | [{timePoint: ms, value: float}] |
buyBox | BuyBox价格 | [{timePoint: ms, value: float}] |
dealPrice | Deal价格 | [{timePoint: ms, value: float}] (value=-1=无Deal) |
def find_deal_periods(deal_prices, gap_days=2):
dates = sorted(deal_prices.keys())
if not dates: return []
periods = []
start = end = dates[0]
for i in range(1, len(dates)):
d1 = datetime.datetime.strptime(dates[i-1], '%Y-%m-%d')
d2 = datetime.datetime.strptime(dates[i], '%Y-%m-%d')
if (d2 - d1).days <= gap_days:
end = dates[i]
else:
periods.append({'start': start, 'end': end, 'deal_price': deal_prices[start]})
start = end = dates[i]
periods.append({'start': start, 'end': end, 'deal_price': deal_prices[start]})
return periods
使用内置脚本:
cd /Users/dino/.hermes/skills/amazon-competitor-keepa-monitor
python scripts/generate_excel.py --keepa /tmp/keepa_data.json --details /tmp/asin_details.json --output /tmp/competitive_report.xlsx
import json
DATA = {}
for asin in ASINS:
kd = keepa[asin]
detail = details.get(asin, {})
# 合并所有日期
all_dates = set()
for field in ['price', 'bsr', 'buyBox', 'dealPrice']:
all_dates.update(kd.get(field, {}).keys())
dates = sorted(all_dates)
DATA[asin] = {
'brand': detail.get('brand', '-'),
'title': detail.get('title', '-')[:80],
'image': detail.get('image', ''),
'dates': dates,
'prices': [kd.get('price', {}).get(d) for d in dates],
'bsrs': [kd.get('bsr', {}).get(d) for d in dates],
'sub_bsrs': [kd.get('subBsr', {}).get(d) for d in dates], # 如果有的话
'reviews': [kd.get('reviews', {}).get(d) for d in dates], # 注意:keepa_info 可能没有 reviews
'ratings': [kd.get('rating', {}).get(d) for d in dates],
}
data_json = json.dumps(DATA, separators=(',',':'), ensure_ascii=False)
MONTHLY = {}
for asin in ASINS:
kd = keepa[asin]
month_data = {}
for field_name in ['price', 'bsr']:
for date_str, val in kd.get(field_name, {}).items():
month = date_str[:7]
if month not in month_data:
month_data[month] = {'prices': [], 'bsrs': []}
if field_name == 'price':
month_data[month]['prices'].append(val)
else:
month_data[month]['bsrs'].append(val)
# 加上评论首尾
reviews = kd.get('reviews', {})
for month in month_data:
month_dates = [d for d in sorted(reviews.keys()) if d.startswith(month)]
if month_dates:
month_data[month]['reviews_start'] = reviews[month_dates[0]]
month_data[month]['reviews_end'] = reviews[month_dates[-1]]
MONTHLY[asin] = month_data
monthly_json = json.dumps(MONTHLY, separators=(',',':'), ensure_ascii=False)
def generate_asin_cards(DATA, ASINS):
cards = []
for i, asin in enumerate(ASINS):
d = DATA[asin]
prices = [p for p in d['prices'] if p is not None]
bsrs = [b for b in d['bsrs'] if b is not None]
reviews = [r for r in d['reviews'] if r is not None]
current_price = f"${prices[-1]:.2f}" if prices else '-'
current_bsr = f"BSR {bsrs[-1]:,}" if bsrs else '-'
review_growth = f"+{reviews[-1] - reviews[0]:,}" if len(reviews) > 1 else '-'
img_url = d.get('image', '')
active = ' active' if i == 0 else ''
card = f'''<div class="asin-card{active}" onclick="selectAsin('{asin}', this)" id="card-{asin}">
<img src="{img_url}" alt="{asin}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 60 60%22><rect fill=%22%231f2937%22 width=%2260%22 height=%2260%22/></svg>'">
<div class="info">
<div class="brand">{d['brand']} | {asin}</div>
<div class="title">{d['title'][:60]}</div>
<div style="margin-top:8px;font-size:.8em">
<span class="tag tag-green">{current_price}</span>
<span class="tag tag-blue">{current_bsr}</span>
<span class="tag tag-yellow">评论 {review_growth}</span>
</div>
</div>
</div>'''
cards.append(card)
return '\n'.join(cards)
asin_cards_html = generate_asin_cards(DATA, ASINS)
# 读取模版
skill_dir = '/Users/dino/.hermes/skills/amazon-competitor-keepa-monitor'
with open(f'{skill_dir}/templates/report.html') as f:
template = f.read()
# 替换占位符
html = template
html = html.replace('{REPORT_TITLE}', 'iPhone 15 手机壳')
html = html.replace('{DATE_RANGE}', '2025-11-21 ~ 2026-05-20')
html = html.replace('{GENERATE_TIME}', '2026-05-21 22:03')
html = html.replace('{COUNT}', str(len(ASINS)))
html = html.replace('{ASIN_CARDS_HTML}', asin_cards_html)
html = html.replace('{DATA_JSON}', data_json)
html = html.replace('{MONTHLY_JSON}', monthly_json)
html = html.replace('{ASIN_LIST_JSON}', json.dumps(ASINS))
html = html.replace('{VARIANT_ANALYSIS_JSON}', '""') # 可选
html = html.replace('{COUNTER_STRATEGY_JSON}', '""') # 可选
# 写入文件
with open('/tmp/competitive_report.html', 'w') as f:
f.write(html)
curl -sL -X POST "https://report.dinzee.ai/report/upload" \
-H "Authorization: 538f4ad962266f5bc62dabda825e43021820988fdeeca85caaa1aca20e49a0eb" \
-F "file=@/tmp/competitive_report.html;filename=report.html"
Excel 同理:
curl -sL -X POST "https://report.dinzee.ai/report/upload" \
-H "Authorization: 538f4ad962266f5bc62dabda825e43021820988fdeeca85caaa1aca20e49a0eb" \
-F "file=@/tmp/competitive_report.xlsx;filename=competitive_report.xlsx"
def bsr_to_daily_sales(bsr):
if bsr <= 10: return 50
if bsr <= 50: return 30
if bsr <= 100: return 20
if bsr <= 500: return 10
if bsr <= 1000: return 5
if bsr <= 5000: return 2
return 1
timePoint 过滤,单位毫秒mcporter call 会截断 >64KB 响应,必须用直接 HTTP/Users/dino/.openclaw/workspace/config/mcporter.json(不是 ~/.hermes/mcporter.json)allMonths 变量会自动从 MONTHLY 数据中提取月份,无需手动指定