Skill: cstdr/xhs-monitor (version 1.0.1)

--- name: xhs-monitor description: 小红书竞品监控 - 自动采集竞品笔记，推送飞书通知，写入数据看板 homepage: https://github.com/openclaw/xhs-monitor metadata: { "openclaw": { "emoji": "📕", "requires": { "bins": ["node"], "env": ["PATH"] }, "install": [ { "id": "puppeteer", "kind": "npm", "module": "puppeteer-core", "label": "安装 puppeteer-core" } ] } } --- # 小红书竞品监控系统 (xhs-monitor) 自动采集小红书竞品账号的笔记，解析有价值内容，推送到飞书。 ## 功能 - ✅ 自动采集多个竞品账号的笔记 - ✅ 本地去重（避免重复推送） - ✅ 智能解析（提取标题、正文、商品、直播时间） - ✅ 飞书卡片推送 - ✅ 定时自动运行（14:06 / 18:06 / 21:06） - ✅ 支持手动触发 ## 安装 ### 方式一：使用 ClawHub 安装（推荐） ```bash # 安装 Skill npx clawhub@latest install xhs-monitor # 或使用 clawhub CLI clawhub install xhs-monitor ``` ### 方式二：手动安装 ```bash # 克隆项目 git clone https://github.com/你的用户名/xhs-monitor.git cd xhs-monitor # 安装依赖 npm install puppeteer-core ``` ### 3. 配置 ```bash # 复制配置模板 cp config.example.js config.js cp notify.example.js notify.js ``` ### 4. 配置账号 编辑 `config.js`： ```javascript // 竞品账号列表（必填） // 从小红书用户主页URL获取：xiaohongshu.com/user/profile/用户ID const ACCOUNTS = [ { name: '账号名1', id: '用户ID1' }, { name: '账号名2', id: '用户ID2' }, ]; // 账号主页URL映射（用于跳转链接） const ACCOUNT_URLS = { '账号名1': 'https://www.xiaohongshu.com/user/profile/用户ID1', '账号名2': 'https://www.xiaohongshu.com/user/profile/用户ID2', }; ``` ### 5. 启动浏览器（调试模式） 首次需要手动启动浏览器并扫码登录： ```bash # Mac export CHROMIUM_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "$CHROMIUM_PATH" \ --remote-debugging-port=9223 \ --user-data-dir="$HOME/xhs-monitor/data/browser" \ "https://www.xiaohongshu.com/" # 或使用 run.sh（自动检测） bash run.sh ``` 扫码登录后，浏览器保持打开状态即可。 ### 6. 运行 ```bash cd xhs-monitor/src node main.js ``` ## 配置说明 ### config.js | 参数 | 说明 | 示例 | |------|------|------| | ACCOUNTS | 监控的账号列表 | `{ name: '账号名', id: '用户ID' }` | | ACCOUNT_URLS | 账号主页URL映射 | 用于跳转链接 | ### notify.js（可选） 如需使用飞书通知，需要： 1. 配置飞书应用权限：`im:message.send_as_user` 2. 如需写入多维表格，配置 BITABLE_CONFIG ## 项目结构 ``` xhs-monitor/ ├── src/ │ ├── main.js # 主程序入口 │ ├── config.js # 账号配置 ⚠️ 需复制 config.example.js │ ├── config.example.js # 配置模板 │ ├── notify.js # 飞书推送 ⚠️ 需复制 notify.example.js │ ├── notify.example.js # 推送模板 │ ├── parser.js # 内容解析 │ ├── dedupe.js # 去重模块 │ └── scraper.js # 浏览器采集 ├── data/ │ └── history.csv # 历史记录（自动生成） └── README.md ``` ## 使用方式 ### 手动运行 ```bash node src/main.js ``` ### 定时任务（Mac） 编辑 crontab： ```bash crontab -e # 添加以下行： 6 14 * * * /usr/bin/node /path/to/xhs-monitor/src/main.js 6 18 * * * /usr/bin/node /path/to/xhs-monitor/src/main.js 6 21 * * * /usr/bin/node /path/to/xhs-monitor/src/main.js ``` ## 常见问题 ### Q: 浏览器需要一直开着吗？ A: 是的，首次登录后保持浏览器打开，程序会复用会话。 ### Q: 为什么抓不到内容？ A: 检查浏览器是否以调试模式运行（--remote-debugging-port=9222）。 ### Q: 如何添加新账号？ A: 编辑 config.js 中的 ACCOUNTS 数组。 ## 待优化 - [ ] 多维表格自动写入 - [ ] LLM智能解析 - [ ] 笔记详情链接获取 - [ ] 登录状态自动检测

path: dedupe.js
content: /**
 * 模块3: 本地去重 + 模块4: LLM信息提取
 */

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

// 历史记录文件
const HISTORY_FILE = path.join(__dirname, '../data/history.csv');

// 读取历史记录
const loadHistory = () => {
  try {
    if (fs.existsSync(HISTORY_FILE)) {
      const content = fs.readFileSync(HISTORY_FILE, 'utf-8');
      const lines = content.split('\n').filter(l => l.trim());
      // 返回第一列（noteId）集合
      return new Set(lines.slice(1).map(l => l.split(',')[0]));
    }
  } catch (e) {
    console.log('读取历史记录失败:', e.message);
  }
  return new Set();
};

// 保存到历史记录
const saveToHistory = (notes) => {
  const timestamp = new Date().toISOString();
  let content = '';
  
  if (fs.existsSync(HISTORY_FILE)) {
    content = fs.readFileSync(HISTORY_FILE, 'utf-8');
  } else {
    content = 'noteId,accountName,fetchTime,title\n';
  }
  
  notes.forEach(n => {
    content += `${n.noteId},${n.accountName},${timestamp},"${n.text.split('\n')[0]}"\n`;
  });
  
  fs.writeFileSync(HISTORY_FILE, content);
};

// 去重
const deduplicate = (notes, history) => {
  const newNotes = [];
  const duplicateNotes = [];
  
  notes.forEach(n => {
    if (history.has(n.noteId)) {
      duplicateNotes.push(n);
    } else {
      newNotes.push(n);
    }
  });
  
  return { newNotes, duplicateNotes };
};

module.exports = { loadHistory, saveToHistory, deduplicate };


path: scraper.js
content: /**
 * 小红书竞品监控 - 拟人化采集引擎 V2
 * 基于 puppeteer-extra + stealth 插件
 * 优化了 selector 和错误处理
 */

const puppeteerCore = require('puppeteer-core');
const puppeteerExtra = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
const fs = require('fs');
const path = require('path');

// 使用 stealth 插件
puppeteerExtra.use(StealthPlugin());

// Chrome/Chromium 路径
// Windows: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
// Mac: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
// Linux: '/usr/bin/google-chrome'
// 或使用 puppeteer 自动下载的 Chromium
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || null; // 如未设置则尝试自动检测

// 反风控配置
const HUMAN_DELAY = {
  MIN_BEFORE: 1000,
  MAX_BEFORE: 3000,
  MIN_AFTER: 2000,
  MAX_AFTER: 5000,
  TYPE_CHAR: 80,
};

const randomDelay = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;

const humanWait = async (min = HUMAN_DELAY.MIN_BEFORE, max = HUMAN_DELAY.MAX_BEFORE) => {
  const delay = randomDelay(min, max);
  console.log(`  ⏳ 等待 ${delay}ms...`);
  return new Promise(resolve => setTimeout(resolve, delay));
};

const humanScroll = async (page) => {
  await page.evaluate((dist) => window.scrollBy(0, dist), randomDelay(200, 500));
  await humanWait(1000, 3000);
};

// 尝试多个 selector
const tryClick = async (page, selectors) => {
  for (const selector of selectors) {
    try {
      const element = await page.$(selector);
      if (element) {
        await element.click();
        console.log(`  ✅ 点击成功: ${selector}`);
        return true;
      }
    } catch (e) {
      // continue
    }
  }
  console.log(`  ⚠️ 未找到任何可点击元素`);
  return false;
};

// 搜索账号
const searchAccount = async (page, accountName) => {
  console.log(`\n🔍 搜索账号: ${accountName}`);
  
  try {
    // 多种方式尝试找到搜索框
    const searchInputSelectors = [
      '#search-input',
      'input[placeholder*="搜索"]',
      'input.search-input',
      '.search-input input',
      'input[type="search"]',
      'input.xs-search-input',
    ];
    
    let clicked = false;
    for (const selector of searchInputSelectors) {
      try {
        const input = await page.$(selector);
        if (input) {
          await input.click();
          clicked = true;
          console.log(`  ✅ 找到搜索框: ${selector}`);
          break;
        }
      } catch (e) {
        continue;
      }
    }
    
    if (!clicked) {
      // 尝试点击整个搜索区域
      const searchAreaSelectors = ['.search-bar', '.search-box', '#search'];
      for (const selector of searchAreaSelectors) {
        try {
          const area = await page.$(selector);
          if (area) {
            await area.click();
            await humanWait(500, 1000);
            clicked = true;
            console.log(`  ✅ 点击搜索区域: ${selector}`);
            break;
          }
        } catch (e) {
          continue;
        }
      }
    }
    
    if (!clicked) {
      console.log('  ⚠️ 未找到搜索框，尝试直接输入...');
    }
    
    await humanWait(500, 1000);
    
    // 输入账号名
    await page.keyboard.type(accountName, { delay: randomDelay(50, 150) });
    console.log(`  ✅ 输入: ${accountName}`);
    
    await humanWait(1000, 2000);
    
    // 按回车
    await page.keyboard.press('Enter');
    console.log(`  ✅ 按下回车`);
    
    await humanWait(3000, 5000);
    
    // 等待搜索结果
    await page.waitForSelector('.user-list-item, .user-list, .author-card, .search-result', { timeout: 10000 }).catch(() => null);
    
    // 获取搜索结果
    const users = await page.$$('.user-list-item, .user-list .item, .author-card, .search-user-item, [class*="user"]');
    
    if (users.length > 0) {
      const index = Math.min(randomDelay(0, Math.min(2, users.length - 1)), users.length - 1);
      console.log(`  👤 点击第 ${index + 1} 个结果 (共${users.length}个)`);
      await users[index].click();
      await humanWait(2000, 4000);
      return true;
    }
    
    console.log('  ⚠️ 未找到用户搜索结果');
    return false;
    
  } catch (error) {
    console.log(`  ❌ 搜索出错: ${error.message}`);
    return false;
  }
};

// 获取最新笔记
const getLatestNotes = async (page, accountName, maxNotes = 3) => {
  console.log(`\n📄 获取 ${accountName} 的最新笔记...`);
  
  try {
    await humanWait(2000, 4000);
    
    // 滚动加载
    for (let i = 0; i < 3; i++) {
      await humanScroll(page);
    }
    
    // 尝试多种笔记 selector
    const noteSelectors = [
      '.note-item',
      '.feed-item',
      '.post-card',
      '[class*="note-item"]',
      '[class*="feed-item"]',
      '.item',
    ];
    
    let notes = [];
    for (const selector of noteSelectors) {
      notes = await page.$$(selector);
      if (notes.length > 0) {
        console.log(`  ✅ 找到 ${notes.length} 个笔记: ${selector}`);
        break;
      }
    }
    
    if (notes.length === 0) {
      console.log('  ⚠️ 未找到笔记列表');
      return [];
    }
    
    const noteData = [];
    
    for (let i = 0; i < Math.min(maxNotes, notes.length); i++) {
      try {
        const noteInfo = await page.evaluate((el) => {
          // 尝试多种方式获取笔记链接
          const links = el.querySelectorAll('a[href*="/note/"]');
          let url = null;
          let noteId = null;
          
          for (const link of links) {
            const href = link.href;
            if (href && href.includes('/note/')) {
              url = href;
              const match = href.match(/\/note\/([^\/\?]+)/);
              noteId = match ? match[1] : null;
              break;
            }
          }
          
          // 获取文本内容
          let text = '';
          const textSelectors = ['.desc', '.content', '.title', '.text', '.note-desc', '[class*="desc"]'];
          for (const sel of textSelectors) {
            const el2 = el.querySelector(sel);
            if (el2) {
              text += el2.innerText + ' ';
            }
          }
          
          return { url, noteId, text: text.trim().substring(0, 500) };
        }, notes[i]);
        
        if (noteInfo.noteId) {
          noteData.push({
            noteId: noteInfo.noteId,
            url: noteInfo.url,
            text: noteInfo.text,
            accountName: accountName,
          });
          console.log(`  ✅ 笔记 ${i + 1}: ${noteInfo.noteId} - ${noteInfo.text.substring(0, 50)}...`);
        }
        
        await humanWait(500, 1500);
        
      } catch (err) {
        console.log(`  ❌ 获取笔记 ${i + 1} 失败: ${err.message}`);
      }
    }
    
    return noteData;
    
  } catch (error) {
    console.log(`  ❌ 获取笔记出错: ${error.message}`);
    return [];
  }
};

// 主函数
const scrapeAccount = async (accountName) => {
  let browser;
  
  try {
    console.log(`\n========== 开始采集: ${accountName} ==========`);
    
    // 启动浏览器
    browser = await puppeteerCore.launch({
      executablePath: CHROMIUM_PATH,
      headless: false,
      args: [
        '--disable-blink-features=AutomationControlled',
        '--no-sandbox',
        '--disable-setuid-sandbox',
        '--disable-dev-shm-usage',
        '--disable-accelerated-2d-canvas',
        '--no-first-run',
        '--no-zygote',
        '--disable-gpu',
        '--disable-extensions',
        '--disable-background-networking',
        '--disable-default-apps',
        '--disable-sync',
        '--disable-translate',
        '--metrics-recording-only',
        '--mute-audio',
        '--no-report-upload',
        '--hide-scrollbars',
      ],
      userDataDir: path.join(__dirname, '../data/user-data-' + Date.now()),
      defaultViewport: { width: 1280, height: 900 },
    });
    
    const page = await browser.newPage();
    
    // 设置视口
    await page.setViewport({
      width: randomDelay(1200, 1400),
      height: randomDelay(800, 900),
      deviceScaleFactor: randomDelay(1, 2),
    });
    
    // 访问小红书
    console.log('🌐 打开小红书...');
    await page.goto('https://www.xiaohongshu.com/', {
      waitUntil: 'domcontentloaded',
      timeout: 30000,
    });
    
    await humanWait(3000, 6000);
    
    // 检查是否需要登录
    const needLogin = await page.$('text=立即登录, text=登录');
    if (needLogin) {
      console.log('  ⚠️ 检测到登录弹窗，尝试关闭...');
      const closeBtn = await page.$('.close, .icon-close, [class*="close"]');
      if (closeBtn) {
        await closeBtn.click();
        await humanWait(1000, 2000);
      }
    }
    
    // 搜索账号
    const found = await searchAccount(page, accountName);
    
    if (!found) {
      console.log('  ❌ 账号未找到');
      return [];
    }
    
    // 获取笔记
    const notes = await getLatestNotes(page, accountName);
    
    console.log(`\n========== 采集完成: ${accountName} ==========`);
    console.log(`获取到 ${notes.length} 篇笔记\n`);
    
    return notes;
    
  } catch (error) {
    console.error(`  💥 采集出错: ${error.message}`);
    return [];
  } finally {
    if (browser) {
      // await browser.close(); // 调试时保持浏览器打开
    }
  }
};

// 导出
module.exports = { scrapeAccount };

// 直接运行
if (require.main === module) {
  const accountName = process.argv[2] || '示例账号';
  scrapeAccount(accountName).then(notes => {
    console.log('\n📊 采集结果:');
    console.log(JSON.stringify(notes, null, 2));
    process.exit(0);
  });
}


path: notify.example.js
content: /**
 * 模块5: 飞书消息推送
 * 支持：飞书卡片消息 + 多维表格数据写入
 * 
 * 使用说明：
 * 1. 复制 notify.example.js 为 notify.js
 * 2. 填入自己的飞书配置
 */

const ACCOUNTS = {};  // 从 config.js 导入
const BITABLE_CONFIG = {
  app_token: '你的多维表格app_token',
  table_id: '你的表格ID'
};

const createFeishuCard = (accountName, notes) => {
  const valuableNotes = notes.filter(n => n.isValuable).slice(0, 3);
  
  if (valuableNotes.length === 0) return null;
  
  const productItems = [];
  valuableNotes.forEach(n => {
    if (n.productItems && n.productItems.length > 0) {
      productItems.push(...n.productItems);
    }
  });
  
  const liveTimes = [];
  valuableNotes.forEach(n => {
    if (n.liveTime) liveTimes.push(n.liveTime);
  });
  
  // 从config获取账号URL
  const { getAccountUrl } = require('./config');
  const profileUrl = getAccountUrl(accountName) || 'https://www.xiaohongshu.com/';
  
  return {
    msg_type: "interactive",
    card: {
      config: { wide_screen_mode: true, enable_forward: true },
      header: {
        template: "red",
        title: { content: `🚨 竞品情报：${accountName} 有新动作`, tag: "plain_text" }
      },
      elements: [
        {
          tag: "markdown",
          content: `**📝 笔记一句话总结：**\n${valuableNotes[0].title ? '📌 ' + valuableNotes[0].title + '\n' : ''}${valuableNotes[0].content || valuableNotes[0].rawTextSummary}`
        },
        {
          tag: "div",
          fields: [
            { is_short: true, text: { tag: "lark_md", content: `**🛒 提及商品：**\n${productItems.length > 0 ? productItems.join(', ') : '无'}` } },
            { is_short: true, text: { tag: "lark_md", content: `**⏰ 开播时间：**\n<font color='red'>${liveTimes.length > 0 ? liveTimes.join(', ') : '未提及'}</font>` } }
          ]
        },
        { tag: "hr" },
        { tag: "div", text: { tag: "lark_md", content: `**价值判断：** ✅ 有价值 (${valuableNotes.length}条)` } },
        {
          tag: "action",
          actions: [{
            tag: "button",
            text: { content: "📱 跳转小红书", tag: "plain_text" },
            type: "primary",
            value: { action_type: "open_url" },
            multi_url: { url: profileUrl, android_url: profileUrl, ios_url: profileUrl, pc_url: profileUrl }
          }]
        },
        { tag: "note", elements: [{ tag: "plain_text", content: `🤖 OpenClaw 自动抓取 | 仅供内部参考` }] }
      ]
    }
  };
};

module.exports = { createFeishuCard, BITABLE_CONFIG };


path: config.js
content: /**
 * 模块0: 账号配置
 * 
 * 使用说明：
 * 1. 复制 config.example.js 为 config.js（如果不存在）
 * 2. 填入自己的账号
 * 
 * ⚠️ 重要：请勿将包含真实账号信息的 config.js 上传到公开仓库！
 */

// 竞品账号列表
// 从 https://www.xiaohongshu.com/user/profile/ 获取用户ID
const ACCOUNTS = [
  { name: '示例账号1', id: '用户ID1' },
  { name: '示例账号2', id: '用户ID2' },
];

// 账号主页URL映射（用于跳转链接）
const ACCOUNT_URLS = {
  '示例账号1': 'https://www.xiaohongshu.com/user/profile/用户ID1',
  '示例账号2': 'https://www.xiaohongshu.com/user/profile/用户ID2',
};

const fetchAccounts = () => Promise.resolve(ACCOUNTS);
const getAccountUrl = (name) => ACCOUNT_URLS[name] || null;

module.exports = { ACCOUNTS, fetchAccounts, getAccountUrl, ACCOUNT_URLS };


path: run.sh
content: #!/bin/bash
# 小红书竞品监控定时任务
# 
# 使用说明：
# 1. 设置环境变量：export CHROMIUM_PATH="你的Chrome路径"
# 2. 设置数据目录：export XHS_DATA_DIR="$HOME/xhs-monitor/data"
# 3. 添加定时任务

# 配置（可根据需要修改）
CHROMIUM_PATH=${CHROMIUM_PATH:-""}
XHS_DATA_DIR=${XHS_DATA_DIR:-"$HOME/xhs-monitor/data"}
DEBUG_PORT=${DEBUG_PORT:-9223}
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"

# 查找 Chrome（Mac/Linux）
if [ -z "$CHROMIUM_PATH" ]; then
    if [ -f "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then
        CHROMIUM_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
    elif [ -f "/usr/bin/google-chrome" ]; then
        CHROMIUM_PATH="/usr/bin/google-chrome"
    fi
fi

cd "$PROJECT_DIR/src"

# 确保数据目录存在
mkdir -p "$XHS_DATA_DIR"

# 确保浏览器在运行
if ! curl -s "http://localhost:$DEBUG_PORT/json/version" > /dev/null 2>&1; then
    if [ -n "$CHROMIUM_PATH" ] && [ -f "$CHROMIUM_PATH" ]; then
        echo "启动浏览器..."
        "$CHROMIUM_PATH" \
            --remote-debugging-port=$DEBUG_PORT \
            --user-data-dir="$XHS_DATA_DIR/browser" \
            --no-first-run \
            "https://www.xiaohongshu.com/" &
        sleep 5
    else
        echo "警告: 未找到Chrome，请设置 CHROMIUM_PATH 环境变量"
    fi
fi

# 运行主程序
node main.js >> ../logs/cron.log 2>&1


path: main.js
content: /**
 * 小红书竞品监控系统 - 主程序 V5
 * 整合所有模块 + 飞书卡片推送
 */

const fs = require('fs');
const path = require('path');
const puppeteerCore = require('puppeteer-core');
const { loadHistory, saveToHistory, deduplicate } = require('./dedupe');
const { parseNotes } = require('./parser');
const { getAccountUrl, ACCOUNTS: CONFIG_ACCOUNTS } = require('./config');

// 使用config中的账号，如未配置则报错
const ACCOUNTS = CONFIG_ACCOUNTS || [];

if (ACCOUNTS.length === 0) {
  console.error('❌ 请先配置账号！复制 config.example.js 为 config.js 并填入账号');
  process.exit(1);
}

const DEBUG_PORT = 9223;

path: scheduler.js
content: /**
 * 模块1: 定时触发 (Cron)
 * 
 * 执行时间: 14:06, 18:06, 21:06
 */

const { spawn } = require('child_process');
const path = require('path');

// Cron时间配置
const CRON_TIMES = [
  '0 14 * * *',  // 下午2点
  '0 18 * * *',  // 下午6点
  '0 21 * * *',  // 晚上9点
];

// 运行主程序
const runMonitor = () => {
  console.log('\n========== 定时任务触发 ==========');
  console.log('时间:', new Date().toISOString());
  
  const mainPath = path.join(__dirname, 'main.js');
  
  const child = spawn('node', [mainPath], {
    cwd: path.join(__dirname),
    stdio: 'inherit'
  });
  
  child.on('close', (code) => {
    console.log(`\n========== 任务完成 (code: ${code}) ==========`);
  });
};

// 手动触发
const manualRun = () => {
  runMonitor();
};

// 导出
module.exports = { runMonitor, manualRun, CRON_TIMES };

// 如果直接运行
if (require.main === module) {
  manualRun();
}


path: daemon.js
content: /**
 * 小红书竞品监控 - 守护进程
 * 保持浏览器持续运行，不关闭
 */

const puppeteerCore = require('puppeteer-core');
const path = require('path');

// Chrome/Chromium 路径
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || null;
const USER_DATA_DIR = path.join(__dirname, '../data/browser-session');
const PAGE_URL = 'https://www.xiaohongshu.com/';

let browser = null;
let page = null;

const initBrowser = async () => {
  try {
    console.log('🚀 启动浏览器...');
    
    browser = await puppeteerCore.launch({
      executablePath: CHROMIUM_PATH,
      headless: false,
      userDataDir: USER_DATA_DIR,
      defaultViewport: { width: 1280, height: 900 },
      args: [
        '--no-sandbox',
        '--disable-setuid-sandbox',
      ]
    });
    
    page = await browser.newPage();
    
    console.log('🌐 打开小红书...');
    await page.goto(PAGE_URL, { waitUntil: 'networkidle2' });
    
    console.log('✅ 浏览器已就绪！');
    console.log('========================================');
    console.log('请用户在浏览器中操作：');
    console.log('1. 扫码登录小红书');
    console.log('2. 搜索要监控的账号');
    console.log('3. 告诉美绪"好了"开始抓取');
    console.log('========================================');
    
    // 监听页面关闭
    browser.on('disconnected', () => {
      console.log('⚠️ 浏览器断开连接，5秒后重新启动...');
      setTimeout(initBrowser, 5000);
    });
    
  } catch (error) {
    console.error('❌ 启动失败:', error.message);
    console.log('5秒后重试...');
    setTimeout(initBrowser, 5000);
  }
};

// 导出一个控制接口
const getPage = () => page;
const getBrowser = () => browser;

module.exports = { getPage, getBrowser, initBrowser };

// 如果直接运行
if (require.main === module) {
  initBrowser();
  
  // 保持进程
  setInterval(() => {}, 100000);
}


path: config.example.js
content: /**
 * 账号配置模板
 * 
 * 使用方法：
 * 1. 复制此文件为 config.js
 * 2. 填入你的账号信息
 * 
 * ⚠️ 请勿将 config.js 上传到公开仓库！
 */

// 竞品账号列表
// 从小红书用户主页URL获取ID：xiaohongshu.com/user/profile/用户ID
const ACCOUNTS = [
  { name: '你的账号名', id: '小红书用户ID' },
  // 添加更多账号...
];

// 账号主页URL映射（用于跳转链接，可选）
const ACCOUNT_URLS = {
  '你的账号名': 'https://www.xiaohongshu.com/user/profile/小红书用户ID',
};

const fetchAccounts = () => Promise.resolve(ACCOUNTS);
const getAccountUrl = (name) => ACCOUNT_URLS[name] || null;

module.exports = { ACCOUNTS, fetchAccounts, getAccountUrl, ACCOUNT_URLS };


path: parser.js
content: /**
 * 模块4: 正则匹配 + LLM意图解析
 * 提取: 直播时间、商品、笔记类型、标题、正文
 */

// 关键词匹配规则
const KEYWORDS = {
  live: ['直播', '啵啵间', '开播', '今晚', '明晚', '几点', '几点开播', '直播预告'],
  product: ['上新', '新款', '新品', '餐具', '餐盘', '杯子', '摆件', '装饰', '家具', '好物', '推荐', '分享'],
  preview: ['预告', '预告来了', '即将', '要来了', '明天', '周五', '周六', '周日']
};

// 匹配笔记类型
const matchNoteType = (text) => {
  const lowerText = text.toLowerCase();
  if (KEYWORDS.live.some(k => lowerText.includes(k))) return '直播预告';
  if (KEYWORDS.product.some(k => lowerText.includes(k))) return '新品上架';
  return '日常分享';
};

// 提取直播时间
const extractLiveTime = (text) => {
  const patterns = [/今晚(\d+)[点时]/, /明晚(\d+)[点时]/, /今天(\d+)[点时]/, /明天(\d+)[点时]/, /(\d+)[点时]/];
  for (const p of patterns) {
    const m = text.match(p);
    if (m) return m[0];
  }
  return null;
};

// 提取商品
const extractProducts = (text) => {
  const products = [];
  const kw = ['Doing Goods', '新款', '新品', '上新'];
  for (const k of kw) {
    if (text.includes(k)) {
      const idx = text.indexOf(k);
      const after = text.substring(idx + k.length).split('\n')[0].trim();
      if (after && after.length > 1 && after.length < 50) {
        products.push(k + after);
      }
    }
  }
  return products.length > 0 ? products : [];
};

// 提取标题（通常是第一行或包含关键字的短句）
const extractTitle = (text) => {
  const lines = text.split('\n').filter(l => l.trim());
  if (lines.length > 0) {
    // 取第一行作为标题
    return lines[0].trim().substring(0, 100);
  }
  return '';
};

// 提取正文（去掉第一行和账号名等噪音）
const extractContent = (text) => {
  const lines = text.split('\n').filter(l => l.trim());
  // 去掉第一行（标题）和最后几行（账号名、点赞数等）
  const content = lines.slice(1, -2).join(' ').trim();
  return content.substring(0, 200);
};

// 生成一句话总结
const generateSummary = (note) => {
  const parts = [];
  
  if (note.title) parts.push(note.title);
  if (note.content && note.content.length > 0) parts.push(note.content);
  
  let summary = parts.join(' - ').substring(0, 100);
  
  // 如果太短，用原文
  if (summary.length < 20 && note.rawText) {
    summary = note.rawText.split('\n')[0].substring(0, 100);
  }
  
  return summary;
};

// 解析单条笔记
const parseNote = (note) => {
  const text = note.text || '';
  
  // 提取标题
  const title = extractTitle(text);
  
  // 提取正文
  const content = extractContent(text);
  
  // 生成一句话总结
  const rawTextSummary = generateSummary({ title, content, rawText: text });
  
  const result = {
    noteId: note.noteId,
    title: title,
    content: content,
    rawTextSummary: rawTextSummary,
    noteType: matchNoteType(text),
    liveTime: extractLiveTime(text),
    productItems: extractProducts(text),
    isValuable: false
  };
  
  if (result.noteType === '直播预告' || result.noteType === '新品上架' || result.productItems.length > 0) {
    result.isValuable = true;
  }
  
  return result;
};

const parseNotes = (notes) => notes.map(parseNote);

module.exports = { parseNotes, parseNote };


path: interactive.js
content: /**
 * 小红书竞品监控 - 交互式登录版本
 * 等待用户手动登录，然后开始抓取
 */

const puppeteerCore = require('puppeteer-core');
const path = require('path');

// Chrome/Chromium 路径
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || null;

const randomDelay = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
const humanWait = async (min = 1000, max = 3000) => {
  return new Promise(resolve => setTimeout(resolve, randomDelay(min, max)));
};

const waitForLogin = async (page) => {
  console.log('\n👤 请用户在小红书页面登录账号...');
  console.log('   登录成功后告诉我，我继续～\n');
  
  // 检查是否已登录 (有用户头像)
  while (true) {
    await humanWait(3000, 5000);
    
    const isLoggedIn = await page.evaluate(() => {
      // 多种检测登录状态的方式
      const avatar = document.querySelector('.avatar, [class*="avatar"], img[class*="user"]');
      const userName = document.querySelector('.username, [class*="userName"], [class*="nick"]');
      
      return {
        hasAvatar: !!avatar,
        hasUserName: !!(userName && userName.innerText),
        url: window.location.href
      };
    });
    
    console.log('检查登录状态:', JSON.stringify(isLoggedIn));
    
    if (isLoggedIn.hasAvatar || isLoggedIn.hasUserName) {
      console.log('\n✅ 检测到已登录！开始抓取...\n');
      return true;
    }
    
    // 每30秒提醒一次
    console.log('   等待登录中... (请在浏览器里登录)');
  }
};

const searchAndScrape = async (page, accountName) => {
  console.log(`\n🔍 搜索账号: ${accountName}`);
  
  try {
    // 点击搜索框
    await page.click('#search-input').catch(async () => {
      const searchArea = await page.$('.search-bar, .search-box, #search');
      if (searchArea) await searchArea.click();
    });
    
    await humanWait(500, 1000);
    
    // 输入
    await page.keyboard.type(accountName, { delay: randomDelay(80, 150) });
    await humanWait(1000, 2000);
    
    // 回车
    await page.keyboard.press('Enter');
    await humanWait(4000, 6000);
    
    // 等待搜索结果
    await page.waitForSelector('[class*="user"], .user-list-item, .author-card', { timeout: 15000 }).catch(() => null);
    
    // 点击第一个用户结果
    const userResult = await page.$('[class*="userList"] a, .user-list-item a, .author-card a, a[href*="/user/"]');
    
    if (userResult) {
      console.log('   点击用户结果...');
      await userResult.click();
      await humanWait(3000, 5000);
      
      // 获取笔记
      const notes = await getNotes(page, accountName);
      return notes;
    }
    
    console.log('   ⚠️ 未找到用户结果');
    return [];
    
  } catch (error) {
    console.log(`   ❌ 搜索出错: ${error.message}`);
    return [];
  }
};

const getNotes = async (page, accountName) => {
  console.log(`\n📄 获取 ${accountName} 的笔记...`);
  
  try {
    // 滚动加载
    for (let i = 0; i < 3; i++) {
      const dist = Math.floor(Math.random() * (600 - 300 + 1)) + 300;
      await page.evaluate((d) => window.scrollBy(0, d), dist);
      await humanWait(1500, 3000);
    }
    
    // 获取笔记链接
    const notes = await page.evaluate(() => {
      const result = [];
      const links = document.querySelectorAll('a[href*="/note/"]');
      
      links.forEach((link, i) => {
        if (i < 3) {
          const noteId = link.href.match(/\/note\/([^\/\?]+)/);
          if (noteId) {
            result.push({
              noteId: noteId[1],
              url: link.href
            });
          }
        }
      });
      
      return result;
    });
    
    console.log(`   获取到 ${notes.length} 篇笔记`);
    return notes.map(n => ({
      ...n,
      text: '', // 暂时不获取文本内容
      accountName
    }));
    
  } catch (error) {
    console.log(`   ❌ 获取笔记出错: ${error.message}`);
    return [];
  }
};

// 主函数
const main = async (accountName) => {
  console.log('========== 小红书竞品监控 ==========\n');
  
  const browser = await puppeteerCore.launch({
    executablePath: CHROMIUM_PATH,
    headless: false,
    userDataDir: path.join(__dirname, '../data/xhs-session'),
    defaultViewport: { width: 1280, height: 900 },
    args: [
      '--disable-blink-features=AutomationControlled',
      '--no-sandbox',
    ]
  });
  
  const page = await browser.newPage();
  
  // 打开小红书
  console.log('🌐 打开小红书...');
  await page.goto('https://www.xiaohongshu.com/', { 
    waitUntil: 'domcontentloaded',
    timeout: 30000 
  });
  
  // 等待登录
  await waitForLogin(page);
  
  // 开始抓取
  const notes = await searchAndScrape(page, accountName);
  
  console.log('\n========== 抓取结果 ==========');
  console.log(JSON.stringify(notes, null, 2));
  
  // 保存结果
  const fs = require('fs');
  const dataPath = path.join(__dirname, '../data/notes.json');
  fs.writeFileSync(dataPath, JSON.stringify(notes, null, 2));
  console.log(`\n📁 结果已保存到: ${dataPath}`);
  
  console.log('\n浏览器保持打开，用户可以查看～');
};

const account = process.argv[2] || '示例账号';
main(account);


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)