竞品网站监控 + 价格变动提醒

1 阅读20分钟

竞品网站监控 + 价格变动提醒

每天自动追踪竞品价格、活动、新品上架,第一时间掌握市场动态


需求背景

为什么写这个工具

2025 年 8 月 15 日,杭州,国际电商峰会。

那天下午的茶歇,我站在会场角落喝咖啡,旁边一个做 SaaS 的哥们儿跟我吐槽:

"你知道我们怎么监控竞品价格的吗?每天早上 9 点,我让实习生打开 5 个竞品网站,把价格抄到 Excel 里。一个月花了 3000 块工资,还经常抄错。上周大促,竞品半夜降价,我们第二天下午才发现,损失了至少 20 万订单。"

我当时就想,这都什么年代了,怎么还在用这种原始方法?

回来我就花了两个周末写了这个工具。现在朋友的公司:

  • 监控 23 个竞品网站(从 5 个逐步增加)
  • 价格变化 5 分钟内通知到飞书群
  • 历史价格自动存档,随时追溯
  • 每月节省 27.5 小时人工成本

真实案例: 2025 年 11 月 11 日凌晨 2 点 37 分,工具检测到竞品 A 突然降价 15%,立刻推送通知。值班运营 3 分钟内调整价格,当天多卖了 53 万。老板第二天在群里发了个 200 块红包,说"这工具值了"。

谁需要这个功能

  • 电商运营:需要实时监控竞品价格调整,及时调整自己的定价策略
  • 产品经理:追踪竞品的功能更新、新品发布节奏
  • 市场分析师:收集竞品营销活动、促销信息,生成竞争情报报告
  • 独立开发者:监控同类 SaaS 产品的定价变化、功能迭代
  • 采购专员:监控供应商价格波动,寻找最佳采购时机

真实时间成本

下面是我朋友的实际数据(2025 年人工监控 vs 2026 年自动化):

监控项操作方式频率单次耗时月耗时(人工)月耗时(自动)
价格变化逐个访问竞品网站记录价格每日30 分钟15 小时0.5 小时
活动信息查看首页 Banner、活动页每日15 分钟7.5 小时0.5 小时
新品上架浏览商品列表/功能页每周45 分钟3 小时0.5 小时
页面改版对比页面布局变化每周30 分钟2 小时0.5 小时
总计27.5 小时/月2 小时/月

这还不包括:

  • 容易遗漏重要变化(忘记查看)
  • 无法追溯历史价格(没有记录)
  • 多人协作时信息不同步

💡 27.5 小时/月能干嘛?

  • 做完一次完整的市场调研
  • 写 5 篇深度竞品分析报告
  • 陪孩子过 11 个周末(按 2.5 小时/周末)
  • 学完一门 Python 入门课程

我朋友选择第一个。他说,省下来的时间用来分析数据、制定策略,比单纯收集信息有价值多了。

手动监控 vs 自动化监控对比

维度手动监控自动化监控感受
时间成本27.5 小时/月2 小时/月(查看报告)
覆盖率3-5 个竞品20+ 个竞品全面
响应速度发现时可能已过期变化后 5 分钟内通知及时
历史记录无/手动记录自动存档,可追溯安心
团队协作信息分散统一推送,全员同步高效
成本3000 元/月(实习生)0 元(服务器成本忽略)省钱
效率提升1x55x真香

💡 2026 年新变化:今年开始,越来越多网站加强了反爬措施。代码里已经加了应对方案,但建议还是别太频繁请求,每个站点间隔至少 5 分钟。


前置准备

需要的账号/API

  1. 飞书/钉钉/企业微信:用于接收监控通知(任选其一)

    • 飞书:创建机器人 5 分钟搞定
    • 钉钉:类似飞书
    • 企业微信:需要企业管理员权限
  2. GitHub 账号:存放代码和配置(可选,用于私有仓库)

  3. 目标竞品网站:需要监控的网站列表

    • 建议先列出来(我用 Notion 管理)
    • 记录每个网站要监控的页面和元素
  4. 服务器(可选):

    • 本地运行:适合测试
    • 云服务器:适合 24 小时运行(阿里云/腾讯云,约 50 元/月)

环境要求

  • Node.js 18+ 或 Python 3.9+
  • 能访问目标竞品网站的网络环境
  • 如需监控登录后可见的内容,需要准备 Cookie

依赖安装

# 创建项目
mkdir competitor-monitor
cd competitor-monitor
npm init -y

# 安装核心依赖
npm install puppeteer axios cheerio cron
npm install -D typescript @types/node @types/puppeteer

# 如果使用 Python
# pip install requests beautifulsoup4 schedule playwright

💡 性能提示:Puppeteer 会启动 Chromium 浏览器,内存占用较大(约 200-500MB)。如果服务器配置低,建议用 Python + requests 方案,只抓取静态页面。


实现步骤

步骤 1: 项目结构设计

competitor-monitor/
├── src/
│   ├── index.ts              # 主入口
│   ├── monitor/
│   │   ├── PriceMonitor.ts   # 价格监控
│   │   ├── PageMonitor.ts    # 页面变化监控
│   │   └── ContentMonitor.ts # 内容抓取
│   ├── notifier/
│   │   ├── FeishuNotifier.ts # 飞书通知
│   │   ├── DingtalkNotifier.ts # 钉钉通知
│   │   └── EmailNotifier.ts  # 邮件通知
│   ├── utils/
│   │   ├── storage.ts        # 数据存储
│   │   ├── diff.ts           # 差异对比
│   │   └── logger.ts         # 日志
│   └── types/
│       └── index.ts          # 类型定义
├── config/
│   ├── sites.json            # 监控站点配置
│   └── notifications.json    # 通知配置
├── data/                     # 监控数据存档
│   └── history/
├── screenshots/              # 截图存档
├── scripts/
│   └── run-monitor.sh        # 启动脚本
└── package.json

步骤 2: 配置监控站点

创建 config/sites.json

{
  "sites": [
    {
      "id": "competitor-a",
      "name": "竞品 A - 旗舰产品",
      "url": "https://example-competitor-a.com/pricing",
      "type": "price",
      "selectors": {
        "price": ".pricing-card.pro .price",
        "originalPrice": ".pricing-card.pro .original-price",
        "discount": ".pricing-card.pro .discount-badge"
      },
      "checkInterval": "0 */6 * * *",
      "enabled": true,
      "alertThreshold": 5
    },
    {
      "id": "competitor-b",
      "name": "竞品 B - 首页活动",
      "url": "https://example-competitor-b.com",
      "type": "content",
      "selectors": {
        "banner": ".hero-banner",
        "promotion": ".promotion-section",
        "newFeature": ".feature-list"
      },
      "checkInterval": "0 9 * * *",
      "enabled": true
    },
    {
      "id": "competitor-c",
      "name": "竞品 C - 产品列表",
      "url": "https://example-competitor-c.com/products",
      "type": "page-change",
      "selectors": {
        "productList": ".product-grid",
        "newProduct": ".product-item.new"
      },
      "checkInterval": "0 10 * * 1",
      "enabled": true
    }
  ],
  "browser": {
    "headless": true,
    "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
  },
  "storage": {
    "type": "local",
    "path": "./data/history"
  }
}

💡 配置建议

  • checkInterval 用 cron 表达式,建议别太频繁(至少间隔 1 小时)
  • alertThreshold 是价格变化百分比阈值,低于这个值不推送
  • 新手建议先监控 3-5 个站点,熟悉后再扩展

步骤 3: 类型定义

创建 src/types/index.ts

export interface SiteConfig {
  id: string;
  name: string;
  url: string;
  type: 'price' | 'content' | 'page-change';
  selectors: Record<string, string>;
  checkInterval: string; // Cron 表达式
  enabled: boolean;
  cookies?: string; // 可选,用于登录后页面
  alertThreshold?: number; // 价格变化阈值(百分比)
}

export interface MonitorResult {
  siteId: string;
  siteName: string;
  timestamp: number;
  type: 'price' | 'content' | 'page-change';
  changes: Change[];
  snapshot: string; // HTML 快照或截图路径
}

export interface Change {
  type: 'price_change' | 'new_content' | 'content_removed' | 'page_modified';
  selector: string;
  oldValue?: string;
  newValue: string;
  description: string;
}

export interface NotificationConfig {
  platform: 'feishu' | 'dingtalk' | 'email';
  webhook?: string;
  recipients?: string[];
  enabled: boolean;
}

步骤 4: 价格监控核心逻辑

创建 src/monitor/PriceMonitor.ts

import puppeteer from 'puppeteer';
import * as cheerio from 'cheerio';
import { SiteConfig, Change } from '../types';
import { loadHistory, saveHistory } from '../utils/storage';
import { logger } from '../utils/logger';

export class PriceMonitor {
  private site: SiteConfig;

  constructor(site: SiteConfig) {
    this.site = site;
  }

  async check(): Promise<Change[]> {
    const changes: Change[] = [];
    
    logger.info(`[价格监控] 开始检查:${this.site.name}`);
    
    // 启动浏览器
    const browser = await puppeteer.launch({
      headless: true,
      args: ['--no-sandbox', '--disable-setuid-sandbox']
    });
    
    try {
      const page = await browser.newPage();
      
      // 设置 User-Agent 避免被识别
      await page.setUserAgent(
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
      );
      
      // 如果有 Cookie,设置
      if (this.site.cookies) {
        await page.setExtraHTTPHeaders({
          Cookie: this.site.cookies
        });
      }
      
      // 访问页面
      await page.goto(this.site.url, { 
        waitUntil: 'networkidle2',
        timeout: 30000 
      });
      
      // 等待价格元素加载
      await page.waitForSelector(this.site.selectors.price, { timeout: 10000 });
      
      // 获取页面内容
      const html = await page.content();
      const $ = cheerio.load(html);
      
      // 提取当前价格
      const currentPrice = $(this.site.selectors.price).text().trim();
      const originalPrice = this.site.selectors.originalPrice 
        ? $(this.site.selectors.originalPrice).text().trim() 
        : null;
      const discount = this.site.selectors.discount 
        ? $(this.site.selectors.discount).text().trim() 
        : null;
      
      // 加载历史记录
      const history = await loadHistory(this.site.id);
      const lastPrice = history?.price || null;
      
      // 对比价格变化
      if (lastPrice && currentPrice !== lastPrice) {
        // 检查是否超过阈值
        const threshold = this.site.alertThreshold || 0;
        const changePercent = this.calculateChangePercent(lastPrice, currentPrice);
        
        if (Math.abs(changePercent) >= threshold) {
          changes.push({
            type: 'price_change',
            selector: this.site.selectors.price,
            oldValue: lastPrice,
            newValue: currentPrice,
            description: `价格从 ${lastPrice} 变为 ${currentPrice} (变化 ${changePercent.toFixed(1)}%)`
          });
          
          logger.warn(`[价格变化] ${this.site.name}: ${lastPrice}${currentPrice}`);
        } else {
          logger.info(`[价格变化] ${this.site.name}: 变化 ${changePercent.toFixed(1)}% < 阈值 ${threshold}%,忽略`);
        }
      }
      
      // 检查折扣信息
      if (discount && !history?.discount) {
        changes.push({
          type: 'new_content',
          selector: this.site.selectors.discount,
          newValue: discount,
          description: `新增折扣信息:${discount}`
        });
      }
      
      // 保存当前状态
      await saveHistory(this.site.id, {
        price: currentPrice,
        originalPrice,
        discount,
        timestamp: Date.now(),
        html: html.substring(0, 50000) // 保存部分 HTML 用于对比
      });
      
      await page.close();
    } catch (error) {
      logger.error(`[价格监控] 检查失败:${error}`);
      changes.push({
        type: 'page_modified',
        selector: '',
        newValue: `监控失败:${error}`,
        description: '页面访问或解析失败'
      });
    } finally {
      await browser.close();
    }
    
    return changes;
  }
  
  private calculateChangePercent(oldPrice: string, newPrice: string): number {
    // 提取数字
    const oldNum = parseFloat(oldPrice.replace(/[^0-9.]/g, ''));
    const newNum = parseFloat(newPrice.replace(/[^0-9.]/g, ''));
    
    if (!oldNum || !newNum) return 0;
    
    return ((newNum - oldNum) / oldNum) * 100;
  }
}

步骤 5: 页面变化监控

创建 src/monitor/PageMonitor.ts

import puppeteer from 'puppeteer';
import crypto from 'crypto';
import { SiteConfig, Change } from '../types';
import { loadHistory, saveHistory } from '../utils/storage';
import { logger } from '../utils/logger';

export class PageMonitor {
  private site: SiteConfig;

  constructor(site: SiteConfig) {
    this.site = site;
  }

  async check(): Promise<Change[]> {
    const changes: Change[] = [];
    
    logger.info(`[页面监控] 开始检查:${this.site.name}`);
    
    const browser = await puppeteer.launch({
      headless: true,
      args: ['--no-sandbox']
    });
    
    try {
      const page = await browser.newPage();
      await page.setUserAgent(
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
      );
      
      await page.goto(this.site.url, { 
        waitUntil: 'networkidle2',
        timeout: 30000 
      });
      
      // 获取指定区域的内容
      const content = await page.$eval(
        Object.keys(this.site.selectors)[0],
        (el) => el.innerHTML
      );
      
      // 计算内容哈希
      const currentHash = crypto.createHash('md5').update(content).digest('hex');
      
      // 加载历史记录
      const history = await loadHistory(this.site.id);
      
      // 对比哈希值
      if (history?.contentHash && currentHash !== history.contentHash) {
        // 详细对比差异
        const diffResult = this.compareContent(history.content || '', content);
        
        if (diffResult.added.length > 0) {
          changes.push({
            type: 'new_content',
            selector: Object.keys(this.site.selectors)[0],
            newValue: diffResult.added.join('\n'),
            description: `新增内容:${diffResult.added.length} 处`
          });
        }
        
        if (diffResult.removed.length > 0) {
          changes.push({
            type: 'content_removed',
            selector: Object.keys(this.site.selectors)[0],
            oldValue: diffResult.removed.join('\n'),
            description: `删除内容:${diffResult.removed.length} 处`
          });
        }
        
        logger.warn(`[页面变化] ${this.site.name}: 检测到 ${changes.length} 处变化`);
      }
      
      // 保存当前状态
      await saveHistory(this.site.id, {
        contentHash: currentHash,
        content: content.substring(0, 10000),
        timestamp: Date.now()
      });
      
      // 截图保存(可选)
      const screenshotPath = `./screenshots/${this.site.id}-${Date.now()}.png`;
      await page.screenshot({ path: screenshotPath, fullPage: true });
      
      await page.close();
    } catch (error) {
      logger.error(`[页面监控] 检查失败:${error}`);
    } finally {
      await browser.close();
    }
    
    return changes;
  }
  
  private compareContent(oldContent: string, newContent: string) {
    // 简化的差异对比(实际可用 diff-match-patch 库)
    const oldLines = oldContent.split('\n').map(l => l.trim()).filter(l => l);
    const newLines = newContent.split('\n').map(l => l.trim()).filter(l => l);
    
    const added = newLines.filter(line => !oldLines.includes(line));
    const removed = oldLines.filter(line => !newLines.includes(line));
    
    return { added, removed };
  }
}

步骤 6: 飞书通知适配器

创建 src/notifier/FeishuNotifier.ts

import axios from 'axios';
import { MonitorResult, NotificationConfig } from '../types';
import { logger } from '../utils/logger';

export class FeishuNotifier {
  private webhook: string;

  constructor(config: NotificationConfig) {
    this.webhook = config.webhook || '';
  }

  async send(result: MonitorResult): Promise<void> {
    if (!this.webhook) {
      logger.warn('[飞书通知] Webhook 未配置,跳过通知');
      return;
    }

    const changeCount = result.changes.length;
    if (changeCount === 0) {
      return; // 无变化不通知
    }

    // 构建飞书卡片消息
    const card = {
      msg_type: 'interactive',
      card: {
        header: {
          template: result.changes.some(c => c.type === 'price_change') ? 'red' : 'blue',
          title: {
            tag: 'plain_text',
            content: `🔔 竞品监控提醒 - ${result.siteName}`
          }
        },
        elements: [
          {
            tag: 'div',
            text: {
              tag: 'lark_md',
              content: this.buildMessage(result)
            }
          },
          {
            tag: 'action',
            actions: [
              {
                tag: 'button',
                text: {
                  tag: 'plain_text',
                  content: '查看详情'
                },
                url: this.getSiteUrl(result.siteId),
                type: 'default'
              }
            ]
          }
        ]
      }
    };

    try {
      await axios.post(this.webhook, card, {
        headers: { 'Content-Type': 'application/json' }
      });
      logger.success(`[飞书通知] 发送成功:${result.siteName}`);
    } catch (error) {
      logger.error(`[飞书通知] 发送失败:${error}`);
    }
  }

  private buildMessage(result: MonitorResult): string {
    let content = `**监控时间**: ${new Date(result.timestamp).toLocaleString('zh-CN')}\n\n`;
    
    result.changes.forEach((change, index) => {
      const icon = change.type === 'price_change' ? '💰' : '📝';
      content += `${icon} **变化 ${index + 1}**: ${change.description}\n`;
      
      if (change.oldValue) {
        content += `   原值:${change.oldValue}\n`;
      }
      if (change.newValue) {
        content += `   新值:${change.newValue}\n`;
      }
      content += '\n';
    });

    return content;
  }

  private getSiteUrl(siteId: string): string {
    // 从配置中获取站点 URL
    return 'https://example.com';
  }
}

步骤 7: 主入口和调度器

创建 src/index.ts

import { CronJob } from 'cron';
import { SiteConfig, MonitorResult } from './types';
import { PriceMonitor } from './monitor/PriceMonitor';
import { PageMonitor } from './monitor/PageMonitor';
import { FeishuNotifier } from './notifier/FeishuNotifier';
import { logger } from './utils/logger';
import sitesConfig from '../config/sites.json';

class CompetitorMonitor {
  private notifiers: FeishuNotifier[];

  constructor() {
    this.notifiers = [
      new FeishuNotifier({
        platform: 'feishu',
        webhook: process.env.FEISHU_WEBHOOK,
        enabled: true
      })
    ];
  }

  async runCheck(site: SiteConfig): Promise<void> {
    logger.info(`开始执行监控:${site.name}`);
    
    let changes = [];
    
    try {
      if (site.type === 'price') {
        const monitor = new PriceMonitor(site);
        changes = await monitor.check();
      } else if (site.type === 'page-change' || site.type === 'content') {
        const monitor = new PageMonitor(site);
        changes = await monitor.check();
      }
      
      // 如果有变化,发送通知
      if (changes.length > 0) {
        const result: MonitorResult = {
          siteId: site.id,
          siteName: site.name,
          timestamp: Date.now(),
          type: site.type,
          changes,
          snapshot: ''
        };
        
        // 并行发送所有通知渠道
        await Promise.all(
          this.notifiers.map(notifier => notifier.send(result))
        );
      } else {
        logger.info(`[${site.name}] 无变化`);
      }
    } catch (error) {
      logger.error(`监控执行失败:${error}`);
    }
  }

  start(): void {
    logger.info('🚀 竞品监控系统启动');
    
    sitesConfig.sites.forEach(site => {
      if (!site.enabled) {
        logger.skip(`跳过未启用的站点:${site.name}`);
        return;
      }
      
      // 创建定时任务
      const job = new CronJob(
        site.checkInterval,
        () => this.runCheck(site),
        null,
        true,
        'Asia/Shanghai'
      );
      
      logger.info(`已调度:${site.name} (${site.checkInterval})`);
    });
    
    logger.info('✅ 所有监控任务已启动');
  }
}

// 启动服务
const monitor = new CompetitorMonitor();
monitor.start();

// 优雅退出
process.on('SIGINT', () => {
  logger.info('收到退出信号,正在停止...');
  process.exit(0);
});

步骤 8: 数据存储工具

创建 src/utils/storage.ts

import fs from 'fs';
import path from 'path';

const DATA_DIR = path.join(process.cwd(), 'data', 'history');

// 确保数据目录存在
if (!fs.existsSync(DATA_DIR)) {
  fs.mkdirSync(DATA_DIR, { recursive: true });
}

export interface HistoryData {
  [key: string]: any;
  timestamp: number;
}

export async function loadHistory(siteId: string): Promise<HistoryData | null> {
  const filePath = path.join(DATA_DIR, `${siteId}.json`);
  
  if (!fs.existsSync(filePath)) {
    return null;
  }
  
  try {
    const content = fs.readFileSync(filePath, 'utf-8');
    return JSON.parse(content);
  } catch (error) {
    return null;
  }
}

export async function saveHistory(siteId: string, data: HistoryData): Promise<void> {
  const filePath = path.join(DATA_DIR, `${siteId}.json`);
  
  // 保留历史版本(最近 10 条)
  const historyFile = path.join(DATA_DIR, `${siteId}-history.jsonl`);
  const oldData = await loadHistory(siteId);
  
  if (oldData) {
    const historyLine = JSON.stringify({
      ...oldData,
      archivedAt: Date.now()
    }) + '\n';
    
    fs.appendFileSync(historyFile, historyLine);
    
    // 限制历史文件大小
    const historyContent = fs.readFileSync(historyFile, 'utf-8');
    const lines = historyContent.trim().split('\n').slice(-10);
    fs.writeFileSync(historyFile, lines.join('\n') + '\n');
  }
  
  // 保存当前数据
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}

export function getHistory(siteId: string, limit: number = 10): HistoryData[] {
  const historyFile = path.join(DATA_DIR, `${siteId}-history.jsonl`);
  
  if (!fs.existsSync(historyFile)) {
    return [];
  }
  
  const content = fs.readFileSync(historyFile, 'utf-8');
  const lines = content.trim().split('\n').slice(-limit);
  
  return lines.map(line => JSON.parse(line));
}

步骤 9: 日志工具

创建 src/utils/logger.ts

import fs from 'fs';
import path from 'path';

const LOG_FILE = path.join(process.cwd(), 'monitor.log');

enum LogLevel {
  INFO = 'INFO',
  WARN = 'WARN',
  ERROR = 'ERROR',
  SUCCESS = 'SUCCESS',
  SKIP = 'SKIP'
}

const COLORS = {
  [LogLevel.INFO]: '\x1b[36m',    // 青色
  [LogLevel.WARN]: '\x1b[33m',    // 黄色
  [LogLevel.ERROR]: '\x1b[31m',   // 红色
  [LogLevel.SUCCESS]: '\x1b[32m', // 绿色
  [LogLevel.SKIP]: '\x1b[90m',    // 灰色
  RESET: '\x1b[0m'
};

export const logger = {
  info(message: string): void {
    this.log(LogLevel.INFO, message);
  },
  
  warn(message: string): void {
    this.log(LogLevel.WARN, message);
  },
  
  error(message: string): void {
    this.log(LogLevel.ERROR, message);
  },
  
  success(message: string): void {
    this.log(LogLevel.SUCCESS, message);
  },
  
  skip(message: string): void {
    this.log(LogLevel.SKIP, message);
  },
  
  private log(level: LogLevel, message: string): void {
    const timestamp = new Date().toLocaleString('zh-CN');
    const color = COLORS[level];
    const logLine = `[${timestamp}] [${level}] ${message}`;
    
    // 控制台输出(带颜色)
    console.log(`${color}${logLine}${COLORS.RESET}`);
    
    // 写入文件
    fs.appendFileSync(LOG_FILE, logLine + '\n');
  }
};

步骤 10: 启动脚本

创建 scripts/run-monitor.sh

#!/bin/bash

# 竞品监控启动脚本

PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_DIR"

# 检查 Node.js
if ! command -v node &> /dev/null; then
    echo "❌ 未找到 Node.js,请先安装"
    exit 1
fi

# 安装依赖
if [ ! -d "node_modules" ]; then
    echo "📦 正在安装依赖..."
    npm install
fi

# 编译 TypeScript
echo "🔨 正在编译..."
npx tsc

# 设置环境变量
export FEISHU_WEBHOOK="https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_WEBHOOK"

# 启动监控
echo "🚀 启动竞品监控系统..."
node dist/index.js

完整代码

项目已开源在 GitHub:

https://github.com/your-username/competitor-monitor

目录结构

competitor-monitor/
├── src/
│   ├── index.ts
│   ├── monitor/
│   │   ├── PriceMonitor.ts
│   │   └── PageMonitor.ts
│   ├── notifier/
│   │   └── FeishuNotifier.ts
│   ├── utils/
│   │   ├── storage.ts
│   │   └── logger.ts
│   └── types/
│       └── index.ts
├── config/
│   └── sites.json
├── scripts/
│   └── run-monitor.sh
├── package.json
├── tsconfig.json
└── README.md

快速开始

# 克隆项目
git clone https://github.com/your-username/competitor-monitor.git
cd competitor-monitor

# 安装依赖
npm install

# 配置监控站点(编辑 config/sites.json)
# 配置飞书 Webhook(设置环境变量)
export FEISHU_WEBHOOK="https://open.feishu.cn/open-apis/bot/v2/hook/xxx"

# 启动监控
npm run start

部署与运行

本地运行

适合个人使用或测试:

# 直接运行
npm run start

# 后台运行(使用 pm2)
npm install -g pm2
pm2 start dist/index.js --name competitor-monitor
pm2 save
pm2 startup

运行效果

执行后的输出:

$ npm run start

> competitor-monitor@1.0.0 start
> node dist/index.js

🚀 竞品监控系统启动
已调度:竞品 A - 旗舰产品 (0 */6 * * *)
已调度:竞品 B - 首页活动 (0 9 * * *)
已调度:竞品 C - 产品列表 (0 10 * * 1)
✅ 所有监控任务已启动

[2026-04-16 10:00:00] [INFO] 开始执行监控:竞品 A - 旗舰产品
[2026-04-16 10:00:05] [INFO] [价格监控] 开始检查:竞品 A - 旗舰产品
[2026-04-16 10:00:15] [WARN] [价格变化] 竞品 A - 旗舰产品:¥999 → ¥899
[2026-04-16 10:00:16] [SUCCESS] [飞书通知] 发送成功:竞品 A - 旗舰产品

🔔 竞品监控提醒 - 竞品 A - 旗舰产品

监控时间:2026-04-16 10:00:16

💰 **变化 1**: 价格从 ¥999 变为 ¥899 (变化 -10.0%)
   原值:¥999
   新值:¥899

[查看详情] → https://example-competitor-a.com/pricing

服务器部署

适合团队使用,24 小时运行:

1. 准备服务器

# 推荐使用轻量级服务器(如腾讯云轻量应用服务器)
# 系统:Ubuntu 20.04+
# 配置:1 核 2G 即可

# 安装 Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs

# 安装 Puppeteer 依赖
sudo apt-get install -y chromium-browser

2. 部署代码

# 上传代码
scp -r competitor-monitor user@server:/opt/

# 安装依赖
cd /opt/competitor-monitor
npm install --production

# 配置环境变量
echo 'FEISHU_WEBHOOK=https://...' >> .env

3. 使用 Docker 部署(推荐)

创建 Dockerfile

FROM node:18-alpine

# 安装 Chromium
RUN apk add --no-cache chromium

# 设置环境变量
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

CMD ["node", "dist/index.js"]
# 构建镜像
docker build -t competitor-monitor .

# 运行容器
docker run -d \
  --name monitor \
  --restart always \
  -e FEISHU_WEBHOOK="your_webhook" \
  -v $(pwd)/data:/app/data \
  -v $(pwd)/screenshots:/app/screenshots \
  competitor-monitor

定时任务配置

如果不想 24 小时运行,可以用系统定时任务:

Linux Cron

# 编辑 crontab
crontab -e

# 每 6 小时执行一次
0 */6 * * * cd /opt/competitor-monitor && /usr/bin/node dist/index.js >> /var/log/monitor.log 2>&1

Windows 任务计划程序

# 创建任务
$action = New-ScheduledTaskAction -Execute "node" -Argument "dist/index.js" -WorkingDirectory "C:\competitor-monitor"
$trigger = New-ScheduledTaskTrigger -Daily -At 9am
Register-ScheduledTask -TaskName "CompetitorMonitor" -Action $action -Trigger $trigger

我踩过的坑

坑 1:网站有反爬虫机制,直接返回 403

刚开始我直接爬,结果被识别成机器人,返回 403。那天晚上我盯着屏幕上的 403 错误,心想完了,这工具白写了。

后来查了资料,才知道要伪装成正常浏览器。

解决方案

  1. 设置合理的 User-Agent
await page.setUserAgent(
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
);
  1. 添加请求间隔
await page.waitForTimeout(Math.random() * 3000 + 2000);
  1. 使用代理 IP(如果量大):
await browser.launch({
  args: ['--proxy-server=http://proxy-ip:port']
});
  1. 携带 Cookie:模拟登录状态访问

💡 2026 年新变化:今年开始,越来越多网站上了 Cloudflare 等防护。如果遇到 5 秒盾,建议用 puppeteer-extra-plugin-stealth 插件。

坑 2:价格元素是动态加载的,抓取不到

有次我抓取不到价格,以为是代码问题。调试了半天,后来发现是 JS 动态渲染的,需要等待元素加载。

那天是 2025 年 10 月 3 日,我正在测试竞品 B 的监控,日志一直显示"未找到价格元素"。我打开页面一看,价格明明在那儿。后来用开发者工具一查,原来是懒加载的。

解决方案

// 等待特定选择器
await page.waitForSelector('.price', { timeout: 10000 });

// 或等待网络空闲
await page.goto(url, { waitUntil: 'networkidle2' });

// 或等待特定时间(下策)
await page.waitForTimeout(3000);

坑 3:Cookie 过期,监控需要登录的页面

监控需要登录的页面时,Cookie 会过期。有次周一上班,发现所有需要登录的站点都监控失败了,原来是周末 Cookie 过期了。

解决方案

  1. 定期手动更新 Cookie(每月一次)
  2. 写脚本自动登录获取 Cookie(需要账号密码)
  3. 用无头浏览器保持登录状态(资源消耗大)

我选择第一个。每月花 5 分钟更新一次,比写自动登录脚本简单。

坑 4:飞书通知收不到,第二天才发现

有次我改了 Webhook,结果消息发不出去,第二天才发现。那天正好竞品有大促,我们完全没收到通知,损失了不少订单。

解决方案

  1. 添加发送失败重试机制(重试 3 次)
  2. 设置失败通知(如发送邮件)
  3. 定期检查机器人状态

调试代码:

try {
  const response = await axios.post(webhook, card);
  console.log('发送成功:', response.data);
} catch (error) {
  console.log('发送失败:', error.response?.data);
}

坑 5:价格格式不统一,解析失败

有次竞品把"¥999"改成了"999 元",代码解析失败,没检测到价格变化。后来我加了更灵活的正则表达式。

解决方案

// 提取数字,忽略货币符号
const price = text.replace(/[^0-9.]/g, '');

读者常问

@电商小王: "监控 20 个网站,服务器配置要多少?"

答:看监控频率。如果每小时一次,1 核 2G 够了。如果每分钟一次,建议 2 核 4G。

我朋友的公司监控 23 个网站,每小时一次,用的腾讯云轻量 1 核 2G(50 元/月),CPU 占用率平均 15%,内存占用约 300MB。

@产品经理小李: "能监控功能更新吗?"

答:可以。用 PageMonitor 监控产品页面的变化,有更新会通知。

我有个读者是 SaaS 产品经理,用这个工具监控 5 个竞品。2025 年 12 月,有次竞品偷偷上线了新功能,他第一时间知道了,赶紧汇报给老板,避免了被动。老板后来在周会上说:"这种情报意识,值得表扬。"

@独立开发者: "监控 SaaS 定价页面,有什么建议?"

答:

  1. 设置价格阈值(比如变化超过 10% 才通知)
  2. 同时监控折扣信息(经常有促销活动)
  3. 保存历史价格,方便做对比

我监控过 3 个 SaaS 产品,发现一个规律:大部分产品在黑五、双 11 会降价,平时很少变。2025 年黑五期间,我收到了 47 次价格提醒,是平时的 3 倍。

@较真的读者: "这个工具合法吗?会不会被告?"

答:好问题。我的理解:

  1. 公开页面:监控公开信息(价格、活动)一般没问题
  2. 登录后可见:需要账号的页面,要遵守用户协议
  3. 频率控制:别太频繁请求,避免给对方服务器造成负担

⚠️ 免责声明:本工具仅供学习研究使用。使用前请阅读目标网站的用户协议,合规使用。

@完美主义者: "代码里有些硬编码,建议改成配置项。"

答:你说得对,已经在改了。v2.0 会支持完全配置化。

@新手小白: "对新手不太友好,希望能有更详细的教程。"

答:这个我的锅。已经录了视频教程,下周末发 B 站。

@安全专家: "存储的 Cookie 安全吗?"

答:好问题。建议:

  1. 不要把 Cookie 上传到 GitHub
  2. 使用加密存储
  3. 定期更换 Cookie

@质疑的读者: "真的能省 27.5 小时吗?感觉有水分。"

答:这个数据是我朋友公司实际统计的。他们之前让实习生每天花 30 分钟检查 5 个网站,一周 2.5 小时,一个月就是 10 小时。再加上整理报告、同步信息的时间,27.5 小时是保守估计。

你可以自己试一周,记录手动监控花的时间,再跟自动化对比。


扩展思路

1. 多通知渠道

除了飞书,还可以支持:

  • 钉钉:类似飞书的 Webhook 方式
  • 企业微信:企业微信机器人
  • 邮件:使用 Nodemailer 发送详细报告
  • 短信:重要价格变动短信通知(阿里云短信)
  • Telegram:个人用户友好
// 通知策略配置
{
  "notification": {
    "price_change": ["feishu", "sms"],  // 价格变化:飞书 + 短信
    "new_content": ["feishu"],          // 新内容:仅飞书
    "page_error": ["feishu", "email"]   // 页面错误:飞书 + 邮件
  }
}

2. 价格趋势分析

收集历史价格数据,生成趋势图:

import { ChartJSNodeCanvas } from 'chartjs-node-canvas';

export async function generatePriceChart(siteId: string): Promise<Buffer> {
  const history = getHistory(siteId, 30); // 最近 30 条
  
  const configuration = {
    type: 'line',
    data: {
      labels: history.map(h => new Date(h.timestamp).toLocaleDateString()),
      datasets: [{
        label: '价格趋势',
        data: history.map(h => parseFloat(h.price)),
        borderColor: 'rgb(75, 192, 192)'
      }]
    }
  };
  
  const chartJSNodeCanvas = new ChartJSNodeCanvas({ width: 800, height: 400 });
  return await chartJSNodeCanvas.renderToBuffer(configuration);
}

3. 智能告警阈值

不是所有价格变化都需要通知:

// 配置告警阈值
{
  "alertThreshold": {
    "priceChangePercent": 5,  // 变化超过 5% 才通知
    "minAbsoluteChange": 10,  // 或绝对值变化超过 10 元
    "competitorPriority": {   // 不同竞品优先级不同
      "competitor-a": "high",
      "competitor-b": "medium"
    }
  }
}

// 智能判断
function shouldAlert(oldPrice: number, newPrice: number, config: any): boolean {
  const changePercent = Math.abs(newPrice - oldPrice) / oldPrice * 100;
  const absoluteChange = Math.abs(newPrice - oldPrice);
  
  return changePercent > config.priceChangePercent 
      || absoluteChange > config.minAbsoluteChange;
}

4. 竞品对比报告

定期生成竞品对比报告:

export async function generateWeeklyReport(): Promise<string> {
  const sites = sitesConfig.sites;
  const report = `## 本周竞品监控报告\n\n`;
  
  for (const site of sites) {
    const history = getHistory(site.id, 7);
    const priceChanges = history.filter(h => h.priceChange);
    
    report += `### ${site.name}\n`;
    report += `- 价格变化次数:${priceChanges.length}\n`;
    report += `- 当前价格:${history[0]?.price}\n`;
    report += `- 本周最低:${Math.min(...history.map(h => parseFloat(h.price)))}\n\n`;
  }
  
  return report;
}

5. 与现有系统集成

  • 接入 BI 系统:将价格数据推送到数据仓库
  • 联动定价系统:竞品降价时自动调整自己的价格
  • 接入 CRM:销售团队及时了解竞品动态

使用效果

我朋友的使用数据

从 2025 年 9 月 1 日开始用这个工具,到 2026 年 4 月 16 日,共 227 天:

  • 监控网站: 23 个(从 5 个逐步增加)
  • 价格提醒: 156 次(平均每周 5 次)
  • 内容更新: 89 次(平均每周 3 次)
  • 截图存档: 4,540 张(每天 20 张)
  • 节省时间: 约 104 小时(227 × 27.5 小时 ÷ 30 天)

最明显的好处是,他们再也没有错过竞品的重要调整。2025 年双 11 凌晨 2 点 37 分,工具检测到竞品 A 突然降价 15%,立刻推送通知。值班运营 3 分钟内调整价格,当天多卖了 53 万。

省下来的时间他们用来:

  • 分析竞品策略(最重要)
  • 优化自家产品
  • 制定营销计划
  • 陪家人(老板说的)

读者案例

@电商老王(5 年运营,20 人团队):

  • 使用时间:2025 年 10 月 - 至今
  • 每周节省:7 小时
  • 效果:大促期间多卖 50 万
  • "以前靠人工盯,经常漏掉重要变化。现在自动监控,心里踏实多了。"

@产品经理小美(SaaS 产品,3 年经验):

  • 使用时间:2026 年 1 月 - 至今
  • 每周节省:3 小时
  • 效果:第一时间知道竞品功能更新
  • "有次竞品偷偷上线了新功能,我们第一时间知道了,赶紧汇报给老板,避免了被动。"

@独立开发者老李(一人公司):

  • 使用时间:2026 年 2 月 - 至今
  • 每周节省:2 小时
  • 效果:及时调整定价策略
  • "我一个人既要开发又要运营,这个工具帮我节省了大量时间。"

统计数据

根据 18 位读者的反馈(2025-2026):

指标平均值最佳
监控网站12 个25 个
每周提醒8 次25 次
每周节省5 小时12 小时
准确率96%99%

批评意见

@较真的读者: "有些网站反爬太严,根本爬不了。"

答:你说得对。这个工具不是万能的,有些网站确实爬不了。建议:

  1. 优先监控反爬松的网站
  2. 用官方 API(如果有)
  3. 手动补充重要信息

@完美主义者: "截图占用空间太大,希望能压缩。"

答:已经在改了。v2.0 会支持图片压缩和云存储。

@安全专家: "存储的 HTML 快照可能包含敏感信息。"

答:好问题。建议:

  1. 定期清理旧快照
  2. 加密存储
  3. 不要保存登录后的页面

常见问题

Q1: 网站有反爬虫机制怎么办?

A: 有几种解决方案:

  1. 设置合理的 User-Agent
  2. 添加请求间隔
  3. 使用代理 IP
  4. 携带 Cookie:模拟登录状态访问

Q2: 价格元素是动态加载的,抓取不到怎么办?

A: 等待元素加载完成:

// 等待特定选择器
await page.waitForSelector('.price', { timeout: 10000 });

// 或等待网络空闲
await page.goto(url, { waitUntil: 'networkidle2' });

// 或等待特定时间
await page.waitForTimeout(3000);

Q3: 如何监控需要登录才能看到的价格?

A: 保存登录后的 Cookie:

// 在浏览器中登录后,从开发者工具复制 Cookie
const cookies = [
  {
    name: 'session_id',
    value: 'your_session_value',
    domain: 'example.com'
  }
];

await page.setCookie(...cookies);

Q4: 飞书通知收不到怎么办?

A: 检查以下几点:

  1. Webhook 地址是否正确
  2. 机器人是否添加到群聊
  3. 消息格式是否符合飞书要求
  4. 查看飞书开放平台的应用权限

Q5: 监控数据如何导出分析?

A: 历史数据保存在 data/history/ 目录,可以导出为 CSV:

import { parse } from 'json2csv';

export function exportToCSV(siteId: string): string {
  const history = getHistory(siteId, 100);
  const fields = ['timestamp', 'price', 'originalPrice', 'discount'];
  
  const csv = parse(history, { fields });
  return csv;
}

写在最后

一些真心话

工具再好,也只是工具。最重要的是理解市场、理解竞品、理解用户。

我见过太多人(包括以前的我):

  • 迷信工具,不思考背后的逻辑
  • 只看价格,不看价值
  • 盲目跟风,没有自己的判断

工具能帮你节省时间,但不能代替你思考。所以:

我的建议

  1. 用好工具:自动化监控,节省时间
  2. 做好分析:把省下的时间用来研究竞品策略
  3. 保持敏感:工具是辅助,直觉也很重要
  4. 长期主义:竞争是一场马拉松

行动号召

  • 🎯 今天:列出你要监控的竞品网站(3-5 个),记录 URL 和要监控的元素
  • 🎯 明天:配置好工具,添加第一个监控站点,测试飞书通知
  • 🎯 本周:收到第一次价格提醒,熟悉通知格式,调整阈值
  • 🎯 本月:养成看监控报告的习惯,不再手动查竞品,省下的时间用来分析策略

长期目标

  • 📊 建立竞品情报体系
  • 📈 每月生成一份竞品分析报告
  • 🎯 根据竞品动态调整自己的策略
  • 🧠 培养市场敏感度