买东西总是刚下单就降价?本文以 **`price-watch`(商品降价监控器)** 为例,手把手带你写一个真正有用的 OpenClaw Skill,从零开始到

39 阅读7分钟

买东西总是刚下单就降价?本文以 price-watch(商品降价监控器) 为例,手把手带你写一个真正有用的 OpenClaw Skill,从零开始到发布 ClawHub,全流程跑通。


为什么是这个选题?

你有没有这种经历:

在京东收藏了一款机械键盘,想等双十一降价再买——然后忘了。三个月后打开一看,价格早就涨回去了,降价那几天你完全不知道。

price-watch 解决的就是这件事:你说一句"帮我盯着这个商品,降到 X 元告诉我",之后的事情完全不用管

为什么这个方向在 ClawHub 上几乎是空白:

  • 现有爬虫工具都是"拿数据",没有做"持续比较 + 主动通知"的完整闭环
  • 大多数价格监控工具是独立 App,需要单独安装账号,而不是融入你的 AI 工作流
  • 这个场景对技术门槛低、受众广——程序员、买手、普通消费者都用得上

先看效果,再来写代码

完成之后,你只需要对 OpenClaw 说:

帮我监控这个商品的价格:https://item.jd.com/100012043978.html
目标价格:799 元,降到就通知我

OpenClaw 会:

  1. 抓取当前价格,存入本地 price-history.json
  2. 每隔 6 小时自动检查一次(通过 HEARTBEAT 触发)
  3. 价格低于 799 元时,在对话里弹出提醒,并生成价格走势摘要

一、环境准备

# Node.js 18+
node -v

# npm 9+
npm -v

# OpenClaw CLI(用于本地测试)
openclaw --version

# Playwright(用于抓取动态页面,京东/淘宝都是 JS 渲染)
npx playwright install chromium

为什么需要 Playwright? 京东、淘宝的商品价格不在 HTML 源码里,是 JavaScript 动态渲染的。curlfetch 抓到的是空壳,只有真实浏览器渲染后才能拿到价格数字。


二、文件结构

openclaw-skill-price-watch/
├── SKILL.md          # Skill 的大脑:告诉 AI 何时用、怎么用
├── scripts/
│   ├── fetch-price.js    # 核心脚本:抓取商品价格
│   └── check-alert.js    # 比较历史价格,判断是否需要提醒
├── package.json
└── README.md

一个文件一个职责,逻辑清晰,后续扩展也方便。


三、写 SKILL.md(最关键的一步)

SKILL.md 是 Skill 的灵魂文件。OpenClaw 读它,决定"什么时候调用这个 Skill"以及"调用时怎么操作"。它分三段:

---
name: price-watch
version: 1.0.0
author: 你的 GitHub ID
description: 监控商品价格,降到目标价格时主动提醒
tags: [shopping, monitor, price, ecommerce]
---

## Use when

用户说以下任意一种话时,激活这个 Skill:

- "帮我盯着这个商品" / "监控这个链接的价格"
- "这个东西降到 X 元告诉我"
- "我想等降价再买" / "有没有到历史低价"
- 提供了京东/淘宝/亚马逊/拼多多商品链接,并提及"降价"/"价格"/"通知"

## NOT for

- 用户只是问"这个东西多少钱"(单次查询,不需要持续监控)
- 没有提供具体商品链接
- 不涉及价格比较或降价提醒的普通购物建议

## Workflow

执行以下步骤:

### 步骤 1:解析用户输入
从对话中提取:
- `product_url`:商品链接(必须)
- `target_price`:目标价格(可选,未提供则只做记录,不设提醒阈值)
- `check_interval`:检查频率(默认 6 小时)

### 步骤 2:首次抓取
运行 `node scripts/fetch-price.js <product_url>`
将结果(商品名、当前价格、抓取时间)追加写入 `~/.openclaw/price-history.json`

### 步骤 3:立刻比较
如果用户设置了 `target_price`,运行 `node scripts/check-alert.js`
- 如果当前价格 ≤ 目标价格:**立刻告知用户,附商品链接**
- 如果尚未到目标价格:告知用户"已开始监控,当前价格 X 元,目标 Y 元,差 Z 元"

### 步骤 4:设置定时监控
在 HEARTBEAT 配置中注册定时任务:每隔 `check_interval` 自动执行步骤 2 和步骤 3

### 步骤 5:汇报状态
向用户输出:
- 商品名称
- 当前价格 vs 目标价格
- 下次检查时间
- 过去价格走势(如有历史记录,列出最近 5 条)

SKILL.md 的三个关键区域解释:

区域作用写坏了会怎样
Use whenAI 的触发条件判断写太宽泛 → 误触发;写太窄 → 永远不触发
NOT for排除不应触发的场景不写 → AI 会在不合适的场景也调用
WorkflowAI 执行的具体步骤步骤模糊 → AI 自由发挥,结果不可控

四、核心脚本:fetch-price.js

// scripts/fetch-price.js
// 用法:node scripts/fetch-price.js <商品URL>

const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const os = require('os');

const HISTORY_FILE = path.join(os.homedir(), '.openclaw', 'price-history.json');

// 根据域名选择抓取策略
const PLATFORM_CONFIG = {
  'item.jd.com': {
    name: '京东',
    priceSelector: '.price.J-p',
    titleSelector: '.sku-name',
    waitFor: '.price.J-p',
  },
  'detail.tmall.com': {
    name: '天猫',
    priceSelector: '.tm-price',
    titleSelector: '.title-text',
    waitFor: '.tm-price',
  },
  'item.taobao.com': {
    name: '淘宝',
    priceSelector: '.tb-rmb-num',
    titleSelector: '.title-text',
    waitFor: '.tb-rmb-num',
  },
  'www.amazon.cn': {
    name: '亚马逊',
    priceSelector: '#priceblock_ourprice, .a-price .a-offscreen',
    titleSelector: '#productTitle',
    waitFor: '#priceblock_ourprice',
  },
};

function detectPlatform(url) {
  const hostname = new URL(url).hostname;
  for (const [domain, config] of Object.entries(PLATFORM_CONFIG)) {
    if (hostname.includes(domain)) return config;
  }
  // 通用兜底策略
  return {
    name: '未知平台',
    priceSelector: '[class*="price"]:not([class*="original"]):not([class*="del"])',
    titleSelector: 'h1, [class*="title"]',
    waitFor: null,
  };
}

async function fetchPrice(productUrl) {
  const platform = detectPlatform(productUrl);
  console.log(`[price-watch] 正在抓取 ${platform.name} 商品价格...`);

  const browser = await chromium.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  });

  const context = await browser.newContext({
    userAgent:
      'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    viewport: { width: 1280, height: 800 },
  });

  const page = await context.newPage();

  try {
    await page.goto(productUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });

    if (platform.waitFor) {
      await page.waitForSelector(platform.waitFor, { timeout: 15000 }).catch(() => {});
    }

    // 等待价格渲染
    await page.waitForTimeout(2000);

    const priceText = await page.$eval(
      platform.priceSelector,
      (el) => el.textContent.trim()
    ).catch(() => null);

    const title = await page.$eval(
      platform.titleSelector,
      (el) => el.textContent.trim()
    ).catch(() => '未知商品');

    if (!priceText) {
      throw new Error('未能抓取到价格,页面结构可能已更新');
    }

    // 清洗价格:提取数字(支持 "¥799.00"、"799.00元" 等格式)
    const price = parseFloat(priceText.replace(/[^0-9.]/g, ''));

    if (isNaN(price)) {
      throw new Error(`价格解析失败,原始文本:${priceText}`);
    }

    const record = {
      url: productUrl,
      platform: platform.name,
      title: title.slice(0, 80), // 截断过长标题
      price,
      timestamp: new Date().toISOString(),
    };

    // 追加写入历史文件
    saveRecord(record);

    console.log(`[price-watch] 抓取成功:${record.title}`);
    console.log(`[price-watch] 当前价格:¥${price}`);
    console.log(JSON.stringify(record));

    return record;
  } finally {
    await browser.close();
  }
}

function saveRecord(record) {
  // 确保目录存在
  const dir = path.dirname(HISTORY_FILE);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }

  let history = [];
  if (fs.existsSync(HISTORY_FILE)) {
    try {
      history = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
    } catch {
      history = [];
    }
  }

  history.push(record);

  // 每个商品最多保留 90 条历史,防止文件无限膨胀
  const urlHistory = history.filter((r) => r.url === record.url);
  if (urlHistory.length > 90) {
    const toRemove = urlHistory.length - 90;
    let removed = 0;
    history = history.filter((r) => {
      if (r.url === record.url && removed < toRemove) {
        removed++;
        return false;
      }
      return true;
    });
  }

  fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
}

// 入口
const url = process.argv[2];
if (!url) {
  console.error('用法:node scripts/fetch-price.js <商品URL>');
  process.exit(1);
}

fetchPrice(url).catch((err) => {
  console.error(`[price-watch] 抓取失败:${err.message}`);
  process.exit(1);
});

五、提醒脚本:check-alert.js

// scripts/check-alert.js
// 用法:node scripts/check-alert.js <商品URL> <目标价格>

const fs = require('fs');
const path = require('path');
const os = require('os');

const HISTORY_FILE = path.join(os.homedir(), '.openclaw', 'price-history.json');

function checkAlert(productUrl, targetPrice) {
  if (!fs.existsSync(HISTORY_FILE)) {
    console.log('暂无历史数据,请先运行 fetch-price.js');
    return;
  }

  const history = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
  const urlHistory = history
    .filter((r) => r.url === productUrl)
    .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));

  if (urlHistory.length === 0) {
    console.log(`未找到该商品的监控记录:${productUrl}`);
    return;
  }

  const latest = urlHistory[0];
  const currentPrice = latest.price;
  const diff = currentPrice - targetPrice;

  // 输出结构化结果,供 OpenClaw 解析
  const result = {
    title: latest.title,
    platform: latest.platform,
    url: productUrl,
    currentPrice,
    targetPrice,
    diff: parseFloat(diff.toFixed(2)),
    triggered: currentPrice <= targetPrice,
    lastChecked: latest.timestamp,
    recentHistory: urlHistory.slice(0, 5).map((r) => ({
      price: r.price,
      time: r.timestamp,
    })),
    lowestEver: Math.min(...urlHistory.map((r) => r.price)),
  };

  console.log(JSON.stringify(result, null, 2));

  if (result.triggered) {
    console.log(`\n🎯 触发提醒!${latest.title} 当前价格 ¥${currentPrice},已达到目标价 ¥${targetPrice}`);
  } else {
    console.log(`\n⏳ 尚未降价。当前 ¥${currentPrice},目标 ¥${targetPrice},还差 ¥${Math.abs(diff).toFixed(2)}`);
  }
}

const [, , url, targetPriceStr] = process.argv;
if (!url || !targetPriceStr) {
  console.error('用法:node scripts/check-alert.js <商品URL> <目标价格>');
  process.exit(1);
}

checkAlert(url, parseFloat(targetPriceStr));

六、package.json

{
  "name": "openclaw-skill-price-watch",
  "version": "1.0.0",
  "description": "OpenClaw Skill:监控商品价格,降到目标价格时主动提醒",
  "main": "index.js",
  "keywords": [
    "openclaw-skill",
    "price",
    "monitor",
    "shopping",
    "ecommerce"
  ],
  "author": "你的 GitHub ID",
  "license": "MIT",
  "scripts": {
    "test": "node scripts/fetch-price.js https://item.jd.com/100012043978.html"
  },
  "dependencies": {
    "playwright": "^1.43.0"
  },
  "engines": {
    "node": ">=18.0.0"
  }
}

包名必须以 openclaw-skill- 开头,这是 ClawHub 识别 Skill 包的命名规范,不符合则无法上架。


七、本地测试

# 安装依赖
npm install

# 安装 Chromium(首次需要)
npx playwright install chromium

# 测试抓取京东价格
node scripts/fetch-price.js https://item.jd.com/100012043978.html

# 测试降价提醒(假设目标价 799 元)
node scripts/check-alert.js https://item.jd.com/100012043978.html 799

# 在 OpenClaw 中整体测试
openclaw "帮我监控这个商品:https://item.jd.com/100012043978.html,降到 799 元告诉我"

常见调试问题速查:

问题原因解决方法
价格返回 NaNCSS 选择器失效,平台改版打开 DevTools 重新找价格元素
超时 timeout网速慢或反爬拦截增加 waitForTimeout,或加代理
抓到的是划线价选择器匹配到原价而非促销价细化选择器,排除 .del / .original
历史文件越来越大商品过多脚本已内置每商品最多 90 条的自动裁剪

八、发布到 ClawHub

第一步:发布到 npm

# 登录 npm(没有账号先去 npmjs.com 注册)
npm login

# 发布
npm publish --access public

发布成功后,你的包地址是: https://www.npmjs.com/package/openclaw-skill-price-watch

第二步:在 ClawHub 登记

打开 clawhub.com/submit,填写:

字段内容
npm 包名openclaw-skill-price-watch
分类productivity / tools
简介监控商品价格,达到目标价格时主动提醒
截图/演示可附命令行运行截图

1-2 小时后自动审核,审核通过即可被所有人通过 clawhub install openclaw-skill-price-watch 安装。


九、进阶方向

完成基础版之后,你可以继续往这几个方向扩展:

1. 多平台价格对比

同一个商品关键词,同时查询京东/淘宝/拼多多的价格,自动选出最低价平台推荐给用户。

2. 微信/飞书推送

价格触达阈值时,不只是在 OpenClaw 对话里通知,同时通过 webhook 推送到微信群或飞书机器人。

// 飞书 webhook 推送示例
async function notifyFeishu(message) {
  await fetch(process.env.FEISHU_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ msg_type: 'text', content: { text: message } }),
  });
}

3. 价格走势图

把历史价格数据导出成 CSV,或者用 echarts-node-canvas 生成一张折线图,直接在对话里展示价格曲线。

4. 历史最低价对比

在抓取当前价格时,同步与 lowestEver 比较——如果当前价格是历史最低,无论是否达到目标价,都值得提醒。


最后一句话

price-watch 这个 Skill 本质上做了一件事:把"我记得要盯价格"这件需要人类工作记忆的事,彻底外包给 AI

你不需要每天打开购物 App 检查,不需要记住什么时候收藏了什么东西——你只要表达过一次意图,AI 替你持续盯着,直到事情发生。

这才是 AI Agent 真正该做的事。