亚马逊 Movers and Shakers 数据:你追踪品类趋势的方式已经落后了吗?

0 阅读3分钟

amazon-movers-shakers-data-guide-cover.jpeg 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 类场景:

  1. 竞品断货:原本占据头部的竞品库存耗尽,市场份额向次级排名商品迁移
  2. 社媒爆款:TikTok、YouTube 或 Instagram 种草导致的快速流量集中
  3. 季节节点:品类进入旺季的自然流量提升(但季节类信号区分度低,需交叉验证)
  4. 平台促销前置: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 交叉验证。