Tags:
#JavaScript #Node.js #亚马逊 #API接入 #电商数据
前言:一个数据窗口,决定你的先发位置
亚马逊 Movers and Shakers 榜单每小时更新,追踪的是 BSR 排名在 24 小时内涨幅最大的商品。这不是最畅销榜——那是结果;MnS 是过程,是市场需求爆发的早期信号。
一款商品从排名 #80,000 在 24 小时内冲到 #1,200,涨幅高达 6,567%,在 Best Sellers 榜上完全看不到,在 MnS 榜上它就在第一页。这个信号对卖家意味着:广告窗口还开着,备货窗口还开着,而如果你等到竞品分析工具把数据推送给你——往往窗口已经关了一半。
本文提供基于 Node.js + Pangolinfo Scrape API 的完整实现,包含 TypeScript 类型定义、并发控制和飞书通知集成。
核心原理:MnS 数据的信号价值
BSR 涨幅的背后通常对应 4 类场景:
- 竞品断货:原本占据头部的竞品库存耗尽,市场份额向次级排名商品迁移
- 社媒爆款:TikTok、YouTube 或 Instagram 种草导致的快速流量集中
- 季节节点:品类进入旺季的自然流量提升(但季节类信号区分度低,需交叉验证)
- 平台促销前置:Prime Day 或 Black Friday 前的搜索量预热
前两类信号的窗口期通常在 6—24 小时,这就是为什么数据延迟以"小时"计算的代价是真实的。
TypeScript 实现:Node.js 版本
安装依赖
npm install axios dotenv
npm install --save-dev @types/node typescript
类型定义
// types/mns.ts
export interface MnSItem {
asin: string;
title: string;
current_rank: number;
previous_rank: number;
rank_gain_pct: number;
rank_gain_absolute: number;
price: number | null;
currency: string;
rating: number | null;
review_count: number | null;
is_prime: boolean;
badge: string | null;
image_url: string;
listing_url: string;
}
export interface MnSResponse {
status: "success" | "error";
category_id: string;
category_name: string;
retrieved_at: string;
items: MnSItem[];
}
export interface AlertItem extends MnSItem {
category_id: string;
opportunity_score: number;
competition_open: boolean;
detected_at: string;
}
采集模块
// collector/mns-fetcher.ts
import axios, { AxiosInstance } from "axios";
import { MnSResponse } from "../types/mns";
const API_ENDPOINT = "https://api.pangolinfo.com/scrape";
export class MnSCollector {
private client: AxiosInstance;
constructor(apiKey: string) {
this.client = axios.create({
baseURL: API_ENDPOINT,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: 45_000,
});
}
async fetchCategory(
categoryId: string,
locale: "us" | "uk" | "de" | "jp" = "us"
): Promise<MnSResponse | null> {
const domainMap: Record<string, string> = {
us: "amazon.com",
uk: "amazon.co.uk",
de: "amazon.de",
jp: "amazon.co.jp",
};
const payload = {
url: `https://www.${domainMap[locale]}/gp/movers-and-shakers/${categoryId}`,
parse_type: "movers_shakers",
output_format: "json",
locale,
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const { data } = await this.client.post<MnSResponse>("", payload);
return data;
} catch (error: any) {
const status = error.response?.status;
console.warn(
`[Attempt ${attempt}/3] Category ${categoryId}: HTTP ${status ?? "timeout"}`
);
if (status === 429) await this.sleep(30_000);
else if (attempt < 3) await this.sleep(5_000 * attempt);
}
}
return null;
}
async fetchBatch(
categoryIds: string[],
concurrency: number = 8
): Promise<(MnSResponse | null)[]> {
const results: (MnSResponse | null)[] = [];
for (let i = 0; i < categoryIds.length; i += concurrency) {
const batch = categoryIds.slice(i, i + concurrency);
const batchResults = await Promise.all(
batch.map((id) => this.fetchCategory(id))
);
results.push(...batchResults);
if (i + concurrency < categoryIds.length) {
await this.sleep(2_000); // 批次间隔
}
}
return results;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
预警引擎
// engine/alert-engine.ts
import { MnSItem, AlertItem } from "../types/mns";
const GAIN_THRESHOLD = 1000; // 涨幅阈值 %
const REVIEW_GATE = 300; // 竞争壁垒评论数
function computeScore(item: MnSItem): number {
const gainScore = Math.min(50, item.rank_gain_pct / 200);
const reviews = item.review_count ?? 9999;
const compScore =
reviews < 50 ? 30 : reviews < 150 ? 20 : reviews < 300 ? 10 : 0;
const rating = item.rating ?? 0;
const ratingScore = rating >= 3.0 ? ((rating - 3.0) / 2.0) * 20 : 0;
return Math.round(gainScore + compScore + ratingScore);
}
export function extractAlerts(
categoryId: string,
items: MnSItem[]
): AlertItem[] {
return items
.filter((item) => item.rank_gain_pct >= GAIN_THRESHOLD)
.map((item) => ({
...item,
category_id: categoryId,
opportunity_score: computeScore(item),
competition_open: (item.review_count ?? 9999) < REVIEW_GATE,
detected_at: new Date().toISOString(),
}))
.sort((a, b) => b.opportunity_score - a.opportunity_score);
}
飞书通知集成
// notify/feishu.ts
import axios from "axios";
import { AlertItem } from "../types/mns";
const FEISHU_WEBHOOK = process.env.FEISHU_WEBHOOK_URL ?? "";
export async function sendFeishuAlert(alerts: AlertItem[]): Promise<void> {
if (!alerts.length || !FEISHU_WEBHOOK) return;
const topAlerts = alerts.slice(0, 5);
const elements = topAlerts.map((a) => ({
tag: "div",
text: {
content:
`**[${a.opportunity_score}分]** ASIN: \`${a.asin}\`\n` +
`涨幅: **+${a.rank_gain_pct.toFixed(0)}%** | BSR: #${a.current_rank} | ` +
`评论: ${a.review_count ?? "?"} | ${a.competition_open ? "✅窗口开放" : "⚠️竞争激烈"}\n` +
`${a.title.slice(0, 60)}`,
tag: "lark_md",
},
}));
await axios.post(FEISHU_WEBHOOK, {
msg_type: "interactive",
card: {
header: { title: { content: `⚡ MnS 预警 — ${topAlerts.length} 个高机会商品`, tag: "plain_text" } },
elements,
},
});
}
最佳实践
1. 持续性优于单点涨幅:连续 3 次扫描均在 Top 50 的商品比单次出现 Top 10 更具参考价值,在存储层加入时间维度追踪。
2. 评论数是竞争壁垒的最直接代理指标:<100 通常意味着切入窗口仍开放;>500 则先发优势基本已被既有卖家占据。
3. 季节性品类单独处理:户外、园艺等强季节品类在旺季节点 MnS 信号会失真,建议独立分析阈值或加入 Google Trends 交叉验证。