From 07fc0fefe12f5846c5e3a2305bb724960c64584f Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Mon, 18 May 2026 20:26:23 +0800 Subject: [PATCH 01/15] chore: remove docs/ from public tracking --- .gitignore | 3 + .../plans/2025-01-rednote-danmu-bot-plan.md | 953 ------------------ .../2025-01-xiaohongshu-live-bot-design.md | 202 ---- 3 files changed, 3 insertions(+), 1155 deletions(-) delete mode 100644 docs/superpowers/plans/2025-01-rednote-danmu-bot-plan.md delete mode 100644 docs/superpowers/specs/2025-01-xiaohongshu-live-bot-design.md diff --git a/.gitignore b/.gitignore index aa0926a..2fdebe0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules/ dist/ .env *.log + +# Internal development docs +docs/ diff --git a/docs/superpowers/plans/2025-01-rednote-danmu-bot-plan.md b/docs/superpowers/plans/2025-01-rednote-danmu-bot-plan.md deleted file mode 100644 index 2c5b546..0000000 --- a/docs/superpowers/plans/2025-01-rednote-danmu-bot-plan.md +++ /dev/null @@ -1,953 +0,0 @@ -# RedNoteDanmuBot 实施计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use `sp-subagent-dev` (recommended) or execute tasks directly. - -**目标:** 实现小红书直播间弹幕机器人 MVP,通过 Web 控制面板控制 Playwright 自动发送弹幕 - -**架构:** Node.js + Express + WebSocket 后端 + Vue 3 前端,Playwright 控制浏览器 - -**技术栈:** Node.js, Express, ws, Playwright, Vue 3, Vite - ---- - -### 任务 1: 项目初始化 - -**文件:** -- 创建: `package.json` -- 创建: `server/config.js` -- 创建: `.gitignore` - -- [ ] **步骤 1: 创建 package.json** - ```json - { - "name": "rednote-danmu-bot", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "server": "node server/index.js", - "client:dev": "cd client && npx vite", - "client:build": "cd client && npx vite build", - "dev": "node server/index.js", - "test": "node --experimental-vm-modules node_modules/.bin/jest" - }, - "dependencies": { - "express": "^4.21.0", - "ws": "^8.18.0", - "playwright": "^1.49.0" - }, - "devDependencies": { - "jest": "^29.7.0" - } - } - ``` - -- [ ] **步骤 2: 创建 .gitignore** - ``` - node_modules/ - dist/ - .env - *.log - ``` - -- [ ] **步骤 3: 创建 server/config.js** - ```javascript - export default { - port: 3000, - defaultInterval: 2500, - minInterval: 1500, - maxInterval: 8000, - jitterRatio: 0.3, - randomLengthMin: 3, - randomLengthMax: 6, - headless: false, - retryCount: 1, - maxConsecutiveErrors: 3, - }; - ``` - -- [ ] **步骤 4: 安装依赖** - 运行: `npm install` - 预期: 无报错,node_modules 目录生成 - -- [ ] **步骤 5: 提交** - ```bash - git init - git add -A - git commit -m "chore: init RedNoteDanmuBot project" - ``` - ---- - -### 任务 2: randomizer.js 模块 + 单元测试 - -**文件:** -- 创建: `server/randomizer.js` -- 创建: `server/__tests__/randomizer.test.js` - -- [ ] **步骤 1: 创建目录** - 运行: `mkdir -p server/__tests__` - -- [ ] **步骤 2: 写失败的测试 — 默认生成不包含数字** - ```javascript - // server/__tests__/randomizer.test.js - import { generate } from '../randomizer.js'; - - describe('randomizer', () => { - test('default generate should not contain digits', () => { - for (let i = 0; i < 100; i++) { - const result = generate(); - expect(result).not.toMatch(/[0-9]/); - } - }); - - test('generate should respect length range', () => { - for (let i = 0; i < 50; i++) { - const result = generate({ minLen: 3, maxLen: 6 }); - expect(result.length).toBeGreaterThanOrEqual(3); - expect(result.length).toBeLessThanOrEqual(6); - } - }); - - test('generate should only use allowed char sets', () => { - for (let i = 0; i < 50; i++) { - const result = generate({ useLetters: true, useSymbols: false, useEmojis: false }); - expect(result).toMatch(/^[a-zA-Z]+$/); - } - }); - - test('generate with only emojis should return emojis', () => { - for (let i = 0; i < 20; i++) { - const result = generate({ useLetters: false, useSymbols: false, useEmojis: true, minLen: 2, maxLen: 2 }); - expect(result.length).toBe(2); - } - }); - }); - ``` - -- [ ] **步骤 3: 运行测试确认失败** - 运行: `npx jest server/__tests__/randomizer.test.js 2>&1` - 预期: FAIL — "Cannot find module '../randomizer.js'" - -- [ ] **步骤 4: 写 randomizer.js 实现** - ```javascript - // server/randomizer.js - const LETTERS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - const SYMBOLS = '~!@#$%^&*_+-=:;,.?'; - const EMOJIS = ['😊','😂','❤️','🌹','🎉','🔥','✨','💪','🎊','🎁','😘','🥰','👏','💕','🎈','🌺','🌸','⭐','🌟','💫']; - - function pickRandom(arr) { - return arr[Math.floor(Math.random() * arr.length)]; - } - - function buildPool(options) { - let pool = ''; - if (options.useLetters !== false) pool += LETTERS; - if (options.useSymbols !== false) pool += SYMBOLS; - if (options.useEmojis !== false) pool += EMOJIS.join(''); - return pool; - } - - export function generate(options = {}) { - const minLen = options.minLen ?? 3; - const maxLen = options.maxLen ?? 6; - const length = minLen + Math.floor(Math.random() * (maxLen - minLen + 1)); - const pool = buildPool(options); - - if (!pool) { - throw new Error('At least one character set must be enabled'); - } - - let result = ''; - for (let i = 0; i < length; i++) { - result += pickRandom(pool); - } - return result; - } - - export function applyTemplate(template, prefix, randomStr) { - return template - .replace('{{prefix}}', prefix) - .replace('{{random}}', randomStr); - } - ``` - -- [ ] **步骤 5: 再配置一下 Jest(因为 type: module)** - 在 `package.json` 中补充: - ```json - "jest": { - "transform": {} - } - ``` - -- [ ] **步骤 6: 运行测试确认通过** - 运行: `npx jest server/__tests__/randomizer.test.js 2>&1` - 预期: PASS (4 tests) - -- [ ] **步骤 7: 提交** - ```bash - git add server/randomizer.js server/__tests__/randomizer.test.js package.json - git commit -m "feat: add randomizer module with tests" - ``` - ---- - -### 任务 3: bot.js — Playwright 核心逻辑 - -**文件:** -- 创建: `server/bot.js` - -**注意:** 本模块涉及真实浏览器操作,无法单元测试,通过手动运行验证。 - -- [ ] **步骤 1: 写 bot.js** - ```javascript - // server/bot.js - import { chromium } from 'playwright'; - import { generate, applyTemplate } from './randomizer.js'; - import config from './config.js'; - - export class DanmuBot { - constructor() { - this.browser = null; - this.page = null; - this.running = false; - this._stopRequested = false; - this.sentCount = 0; - this.onLog = null; // callback(logLine) - this.onError = null; // callback(errorMsg) - this.onCount = null; // callback(sentCount) - } - - async connect(url) { - this.browser = await chromium.launch({ headless: config.headless }); - const context = await this.browser.newContext({ - viewport: { width: 1280, height: 720 }, - }); - this.page = await context.newPage(); - await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); - - // 等待直播间加载完成 — 通过检测常见 DOM 元素 - // 小红书直播间常见特征:输入框 .input-area 或相似的 class - try { - await this.page.waitForSelector('input, textarea, [contenteditable="true"]', { timeout: 15000 }); - } catch { - // 用户可能需要扫码登录,给充足时间 - } - - this._log('info', `✅ 已连接到直播间: ${url}`); - return true; - } - - async disconnect() { - await this.stop(); - if (this.browser) { - await this.browser.close(); - this.browser = null; - this.page = null; - } - this._log('info', '🔌 已断开连接'); - } - - async start(prefix, options = {}) { - if (!this.page) throw new Error('未连接到直播间'); - this.running = true; - this._stopRequested = false; - this.sentCount = 0; - - const interval = options.interval ?? config.defaultInterval; - const template = options.template ?? '{{prefix}} {{random}}'; - const randomOpts = { - minLen: options.randomMinLen ?? config.randomLengthMin, - maxLen: options.randomMaxLen ?? config.randomLengthMax, - useLetters: options.useLetters, - useSymbols: options.useSymbols, - useEmojis: options.useEmojis, - }; - - while (!this._stopRequested) { - try { - const randomStr = generate(randomOpts); - const message = applyTemplate(template, prefix, randomStr); - await this._sendMessage(message); - this.sentCount++; - this._log('success', `✅ [${this.sentCount}] 已发送: ${message}`); - if (this.onCount) this.onCount(this.sentCount); - } catch (err) { - this._log('error', `❌ 发送失败: ${err.message}`); - if (this.onError) this.onError(err.message); - } - - // 带抖动的等待 - const jitter = (Math.random() * 2 - 1) * interval * config.jitterRatio; - const waitMs = Math.max(config.minInterval, interval + jitter); - await this._sleep(waitMs); - } - - this.running = false; - this._log('info', '⏹ 已停止发送'); - } - - async stop() { - this._stopRequested = true; - this.running = false; - } - - async _sendMessage(text) { - // 尝试多种可能的输入框选择器(小红书可能有多个版本) - const selectors = [ - 'input[placeholder*="说点什么"]', - 'input[placeholder*="弹幕"]', - 'input[placeholder*="评论"]', - 'textarea[placeholder*="说点什么"]', - '[contenteditable="true"]', - '.input-area input', - '.chat-input input', - ]; - - let inputEl = null; - for (const sel of selectors) { - inputEl = await this.page.$(sel); - if (inputEl) break; - } - - if (!inputEl) { - // 尝试通用的 input 或 textarea - inputEl = await this.page.$('input') || await this.page.$('textarea'); - } - - if (!inputEl) throw new Error('找不到输入框'); - - await inputEl.click(); - await inputEl.fill(''); - await inputEl.type(text, { delay: 50 }); - - // 尝试点击发送按钮 - const sendBtnSelectors = [ - 'button:has-text("发送")', - '[type="submit"]', - '.send-btn', - 'button:has-text("发布")', - ]; - - let sent = false; - for (const sel of sendBtnSelectors) { - const btn = await this.page.$(sel); - if (btn) { - await btn.click(); - sent = true; - break; - } - } - - if (!sent) { - // 按回车发送 - await this.page.keyboard.press('Enter'); - } - } - - _sleep(ms) { - return new Promise(r => setTimeout(r, ms)); - } - - _log(level, message) { - if (this.onLog) this.onLog({ level, message, time: new Date().toISOString() }); - } - } - ``` - -- [ ] **步骤 2: 提交** - ```bash - git add server/bot.js - git commit -m "feat: add Playwright bot module" - ``` - ---- - -### 任务 4: Server — Express + WebSocket - -**文件:** -- 创建: `server/index.js` - -- [ ] **步骤 1: 写 server/index.js** - ```javascript - // server/index.js - import express from 'express'; - import { createServer } from 'http'; - import { WebSocketServer } from 'ws'; - import path from 'path'; - import { fileURLToPath } from 'url'; - import { DanmuBot } from './bot.js'; - import config from './config.js'; - - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const app = express(); - const server = createServer(app); - - // 生产环境托管前端静态文件 - const clientDist = path.join(__dirname, '..', 'client', 'dist'); - app.use(express.static(clientDist)); - app.get('*', (req, res) => { - res.sendFile(path.join(clientDist, 'index.html')); - }); - - // WebSocket - const wss = new WebSocketServer({ server }); - const bot = new DanmuBot(); - - wss.on('connection', (ws) => { - console.log('[WS] 客户端已连接'); - - // 把 bot 的回调绑定到 WebSocket 推送 - bot.onLog = (logEntry) => { - ws.send(JSON.stringify({ type: 'log', ...logEntry })); - }; - bot.onError = (msg) => { - ws.send(JSON.stringify({ type: 'error', message: msg })); - }; - bot.onCount = (count) => { - ws.send(JSON.stringify({ type: 'count', sent: count })); - }; - - ws.on('message', async (data) => { - try { - const msg = JSON.parse(data.toString()); - - switch (msg.type) { - case 'connect': - await bot.connect(msg.url); - ws.send(JSON.stringify({ type: 'status', state: 'connected', url: msg.url })); - break; - - case 'disconnect': - await bot.disconnect(); - ws.send(JSON.stringify({ type: 'status', state: 'disconnected' })); - break; - - case 'start': - await bot.start(msg.prefix, msg.options || {}); - ws.send(JSON.stringify({ type: 'status', state: 'idle' })); - break; - - case 'stop': - await bot.stop(); - ws.send(JSON.stringify({ type: 'status', state: 'idle' })); - break; - - case 'getStatus': - ws.send(JSON.stringify({ - type: 'status', - state: bot.running ? 'running' : (bot.page ? 'connected' : 'disconnected'), - sentCount: bot.sentCount, - })); - break; - - default: - ws.send(JSON.stringify({ type: 'error', message: `未知消息类型: ${msg.type}` })); - } - } catch (err) { - ws.send(JSON.stringify({ type: 'error', message: err.message })); - } - }); - - ws.on('close', () => { - console.log('[WS] 客户端已断开'); - }); - - // 发送初始状态 - ws.send(JSON.stringify({ type: 'status', state: 'disconnected', sentCount: 0 })); - }); - - server.listen(config.port, () => { - console.log(`🕊️ RedNoteDanmuBot 服务已启动: http://localhost:${config.port}`); - }); - ``` - -- [ ] **步骤 2: 提交** - ```bash - git add server/index.js - git commit -m "feat: add Express + WebSocket server" - ``` - ---- - -### 任务 5: Vue 3 前端 — 控制面板 - -**文件:** -- 创建: `client/package.json` -- 创建: `client/index.html` -- 创建: `client/vite.config.js` -- 创建: `client/src/main.js` -- 创建: `client/src/App.vue` -- 创建: `client/src/style.css` - -- [ ] **步骤 1: 创建 client 目录结构** - 运行: `mkdir -p client/src/components` - -- [ ] **步骤 2: 写 client/package.json** - ```json - { - "name": "rednote-danmu-bot-client", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "vue": "^3.5.0" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^5.2.0", - "vite": "^6.0.0" - } - } - ``` - -- [ ] **步骤 3: 写 client/vite.config.js** - ```javascript - import { defineConfig } from 'vite'; - import vue from '@vitejs/plugin-vue'; - - export default defineConfig({ - plugins: [vue()], - server: { - proxy: { - '/ws': { - target: 'ws://localhost:3000', - ws: true, - }, - }, - }, - }); - ``` - -- [ ] **步骤 4: 写 client/index.html** - ```html - - - - - - RedNoteDanmuBot - - -
- - - - ``` - -- [ ] **步骤 5: 写 client/src/main.js** - ```javascript - import { createApp } from 'vue'; - import App from './App.vue'; - import './style.css'; - - createApp(App).mount('#app'); - ``` - -- [ ] **步骤 6: 写 client/src/style.css** - ```css - * { box-sizing: border-box; margin: 0; padding: 0; } - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #f5f5f5; - color: #333; - min-height: 100vh; - } - #app { - max-width: 800px; - margin: 0 auto; - padding: 20px; - } - h1 { - font-size: 24px; - margin-bottom: 20px; - color: #ff2442; - display: flex; - align-items: center; - gap: 8px; - } - .card { - background: white; - border-radius: 12px; - padding: 16px 20px; - margin-bottom: 16px; - box-shadow: 0 2px 8px rgba(0,0,0,0.06); - } - .card h2 { - font-size: 16px; - margin-bottom: 12px; - color: #666; - } - input, select { - width: 100%; - padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 8px; - font-size: 14px; - outline: none; - transition: border-color 0.2s; - } - input:focus { border-color: #ff2442; } - button { - padding: 8px 20px; - border: none; - border-radius: 8px; - font-size: 14px; - cursor: pointer; - transition: opacity 0.2s; - } - button:hover { opacity: 0.85; } - button:disabled { opacity: 0.5; cursor: not-allowed; } - .btn-primary { background: #ff2442; color: white; } - .btn-success { background: #07c160; color: white; } - .btn-danger { background: #fa5151; color: white; } - .btn-default { background: #f0f0f0; color: #333; } - .status-dot { - display: inline-block; - width: 10px; height: 10px; - border-radius: 50%; - margin-right: 6px; - } - .status-dot.connected { background: #07c160; } - .status-dot.disconnected { background: #ccc; } - .status-dot.running { background: #ff2442; animation: pulse 1s infinite; } - .status-dot.error { background: #fa5151; } - @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } - } - .log-area { - background: #1a1a2e; - color: #e0e0e0; - border-radius: 8px; - padding: 12px; - font-family: 'Courier New', monospace; - font-size: 13px; - max-height: 300px; - overflow-y: auto; - line-height: 1.6; - } - .log-area .success { color: #07c160; } - .log-area .error { color: #fa5151; } - .log-area .info { color: #70b8ff; } - .row { display: flex; gap: 12px; align-items: center; } - .row > * { flex: 1; } - .checkbox-group { display: flex; gap: 16px; flex-wrap: wrap; } - .checkbox-group label { display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer; } - .checkbox-group input[type="checkbox"] { width: auto; } - .sent-count { - font-size: 32px; - font-weight: bold; - color: #ff2442; - text-align: center; - } - ``` - -- [ ] **步骤 7: 写 App.vue — 主面板(完整组件)** - ```vue - - - - - ``` - -- [ ] **步骤 8: 安装前端依赖并测试构建** - 运行: - ```bash - cd client && npm install && npx vite build - ``` - 预期: client/dist/ 目录生成 - -- [ ] **步骤 9: 提交** - ```bash - git add client/ - git commit -m "feat: add Vue 3 web control panel" - ``` - ---- - -### 任务 6: 完善 README + 最终联调 - -**文件:** -- 创建: `README.md` - -- [ ] **步骤 1: 写 README.md** - ```markdown - # 🕊️ RedNoteDanmuBot - - 小红书直播间弹幕机器人 — 通过 Web 控制面板自动发送弹幕。 - - ## 快速开始 - - ```bash - # 安装依赖 - npm install - cd client && npm install && cd .. - - # 构建前端 - cd client && npx vite build && cd .. - - # 启动服务 - npm run server - ``` - - 打开浏览器访问 `http://localhost:3000` - - ## 使用说明 - - 1. 在浏览器中打开小红书直播间页面,扫码登录 - 2. 在控制面板输入直播间 URL → 点击「连接」 - 3. 在 Playwright 打开的浏览器中完成扫码登录 - 4. 设置消息前缀、发送间隔、随机字符串配置 - 5. 点击「开始发送」 - - ## 开发 - - ```bash - # 前端开发(热更新) - cd client && npx vite - - # 运行测试 - npx jest - ``` - - ## 配置 - - 见 `server/config.js` - ``` - -- [ ] **步骤 2: 提交** - ```bash - git add README.md - git commit -m "docs: add README" - ``` - -- [ ] **步骤 3: 最终验证** - 运行: `npm run server` - 预期: 服务启动在 http://localhost:3000,浏览器打开可看到控制面板 - ---- - -## Spec 覆盖检查 - -- [x] 技术架构(Node + Express + WS + Playwright + Vue 3)→ 任务 1, 4, 5 -- [x] randomizer 模块 → 任务 2 -- [x] bot.js 弹幕发送逻辑 → 任务 3 -- [x] WebSocket 通信协议 → 任务 4, 5 -- [x] Web 控制面板 UI → 任务 5 -- [x] 频率控制 + 随机抖动 → 任务 3 (bot.js start 方法) -- [x] 随机字符串配置 → 任务 2, 5 -- [x] 登录态通过 Playwright 手动扫码 → 任务 3 (connect 方法) diff --git a/docs/superpowers/specs/2025-01-xiaohongshu-live-bot-design.md b/docs/superpowers/specs/2025-01-xiaohongshu-live-bot-design.md deleted file mode 100644 index 7df15d6..0000000 --- a/docs/superpowers/specs/2025-01-xiaohongshu-live-bot-design.md +++ /dev/null @@ -1,202 +0,0 @@ -# 小红书直播弹幕机器人 — 设计文档 - -## 概述 - -一个通过 Web 控制面板控制浏览器自动发送直播间弹幕的工具,用于在指定小红书直播间中以可控频率发送自定义消息,帮助用户在直播间互动/抢东西。 - -## 技术架构 - -| 层级 | 选型 | 理由 | -|------|------|------| -| 后端运行时 | Node.js | Playwright 官方 Node.js SDK,生态成熟 | -| Web 框架 | Express | 轻量,配合 ws 库做 WebSocket | -| 实时通信 | WebSocket (ws) | 双向实时推送状态 + 控制指令 | -| 浏览器自动化 | Playwright | 模拟真人浏览器,稳定性好 | -| 前端框架 | Vue 3 + Vite | 国内生态友好,控制面板场景简洁 | -| 前端 HTTP 客户端 | fetch + WebSocket 原生 API | 不引入额外依赖 | - -## 项目目录结构 - -``` -xiaohongshu-live-bot/ -├── server/ -│ ├── index.js # Express 入口 + WebSocket 服务 -│ ├── bot.js # Playwright 核心逻辑(浏览器管理 + 弹幕发送) -│ ├── randomizer.js # 随机字符串生成器 -│ └── config.js # 默认配置 -├── client/ -│ ├── index.html -│ ├── vite.config.js -│ └── src/ -│ ├── main.js -│ ├── App.vue # 主面板 -│ ├── components/ -│ │ ├── ConnectionPanel.vue # 直播间连接区 -│ │ ├── ControlPanel.vue # 开始/停止/频率控制 -│ │ ├── MessageInput.vue # 消息前缀 + 随机串配置 -│ │ └── StatusLog.vue # 实时日志 -│ └── style.css -├── docs/ -│ └── superpowers/ -│ └── specs/ -│ └── 2025-01-xiaohongshu-live-bot-design.md -├── package.json -└── README.md -``` - -## 核心模块设计 - -### 1. 连接模块 (bot.js) - -``` -用户输入直播间URL - → Playwright 启动浏览器(有头/无头可配) - → 打开直播间页面 - → 检测页面加载完成(等待特定 DOM 元素出现) - → 准备就绪 → 通知前端 -``` - -- **浏览器实例**:全局单例,复用同一个浏览器上下文 -- **有头/无头**:默认有头(可见浏览器便于调试),支持切换无头模式 -- **登录态**:用户需要先在 Playwright 打开的浏览器中扫码登录小红书 -- **连接状态**:disconnected / connecting / connected / error - -### 2. 弹幕发送模块 (bot.js) - -``` -sendMessage(prefix, randomString) - → 定位输入框(DOM 选择器) - → 清空输入框 - → 输入: prefix + " " + randomString - → 点击发送按钮(或回车) - → 记录发送日志 -``` - -**DOM 选择器策略:** -- 输入框:通过小红书直播间页面的 DOM 结构定位(需要实测) -- 发送按钮:同上 -- 支持选择器配置化,便于页面改版时更新 - -### 3. 随机字符串生成器 (randomizer.js) - -```javascript -// 默认字符集 -const CHARS = { - letters: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', - symbols: '~!@#$%^&*_+-=:;,.?', - emojis: '😊😂❤️🌹🎉🔥✨💪🎊🎁😘🥰👏💕🎈🌺🌸⭐🌟💫' -}; - -generate(options = {}) - // length: 随机串长度(默认 3-6) - // charSets: 使用的字符集(默认 letters + symbols + emojis) - // excludeNumbers: true(硬编码,不含数字) - // customTemplate: 用户自定义格式 -``` - -用户可配置项: -- 随机串长度范围(默认 3-6) -- 启用/禁用某类字符(字母/符号/表情) -- 自定义模板(如 `{{prefix}} {{random}} ~`) - -### 4. 控制逻辑 - -**发送模式:循环发送** -``` -用户点击「开始」 - → 读取间隔配置(默认 2500ms,带 ±800ms 随机抖动) - → 进入循环: - 1. 生成随机字符串 - 2. 发送消息(prefix + " " + randomString) - 3. 等待 interval + randomJitter - 4. 检查是否收到「停止」信号 - → 用户点击「停止」→ 退出循环 -``` - -**频率保护:** -- 基础间隔:用户可配置(建议范围 1500-8000ms) -- 随机抖动:±30% 的基础间隔(防止固定间隔被风控) -- 发送计数:实时显示已发送条数 -- 错误处理:发送失败自动重试 1 次,连续 3 次失败则暂停 - -### 5. WebSocket 消息协议 - -``` -客户端 → 服务端: - { type: 'connect', url: 'https://...' } // 连接直播间 - { type: 'disconnect' } // 断开连接 - { type: 'start', prefix: '76', interval: 2500 } // 开始发送 - { type: 'stop' } // 停止发送 - { type: 'updateConfig', { ... } } // 更新配置 - -服务端 → 客户端: - { type: 'status', state: 'connected' } // 状态更新 - { type: 'log', message: '...', level: 'info' } // 日志 - { type: 'count', sent: 42 } // 发送计数 - { type: 'error', message: '...' } // 错误 -``` - -### 6. 前端界面布局 - -``` -┌──────────────────────────────────────────┐ -│ 小红书直播弹幕机器人 │ -├──────────────────────────────────────────┤ -│ 直播间连接 │ -│ [输入直播间 URL _________________] [连接] │ -│ 状态: ● 已连接 / ○ 未连接 │ -├──────────────────────────────────────────┤ -│ 消息设置 │ -│ 消息前缀: [76 ] │ -│ 随机串长度: [3] ~ [6] │ -│ ☑ 字母 ☑ 符号 ☑ 表情 │ -│ 自定义模板: [{{prefix}} {{random}}] │ -├──────────────────────────────────────────┤ -│ 发送控制 [▶ 开始] [■ 停止] │ -│ 间隔: [2500]ms 已发送: 42 条 │ -├──────────────────────────────────────────┤ -│ 运行日志 │ -│ [12:00:01] ✅ 已发送: 76 Ab3@ 😊 │ -│ [12:00:03] ✅ 已发送: 76 xK!9 🌹 │ -│ [12:00:06] ❌ 发送失败,重试中... │ -└──────────────────────────────────────────┘ -``` - -## 测试策略 - -- Playwright 浏览器操作部分:通过手动测试验证(需要真实小红书直播间) -- randomizer.js:单元测试(Jest),验证字符集、长度、不包含数字 -- WebSocket 协议:集成测试 -- 前端:Vue 组件的手动测试为主 - -## 项目初始化 - -```bash -npm init -y -npm install express ws playwright vue@next vite @vitejs/plugin-vue -npx playwright install chromium -``` - -## MVP 范围 - -第一期实现: -- [x] 手动输入直播间 URL 连接 -- [x] Playwright 打开直播间页面 -- [x] 发送消息(前缀 + 随机串) -- [x] Web 控制面板(开始/停止/状态/日志) -- [x] 频率控制(可配置间隔 + 随机抖动) -- [x] 随机字符串配置(字符集/长度/模板) -- [x] 发送计数显示 - -后续迭代: -- [ ] 小红书表情库内置 -- [ ] 扫码登录 + 直播列表 -- [ ] 协议直发(无需浏览器) -- [ ] 多直播间同时控制 -- [ ] 定时任务/预设方案 - -## 开放问题 - -1. DOM 选择器需要在实际小红书直播间页面验证 -2. 登录态保持策略:需要用户手动扫码登录一次,后续通过 session/cookie 持久化? -3. 是否需要 Docker 容器化方便部署? From 08c7511eef42afd9a4fe90c9c215028651c4b53a Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Mon, 18 May 2026 20:58:24 +0800 Subject: [PATCH 02/15] feat: add SQLite persistence module (database.js) --- .gitignore | 6 + package-lock.json | 414 +++++++++++++++++++++++++++++- package.json | 5 +- server/__tests__/database.test.js | 110 ++++++++ server/database.js | 102 ++++++++ 5 files changed, 633 insertions(+), 4 deletions(-) create mode 100644 server/__tests__/database.test.js create mode 100644 server/database.js diff --git a/.gitignore b/.gitignore index 2fdebe0..7311663 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,9 @@ dist/ # Internal development docs docs/ + +# Playwright persistent profile (login session cache) +.chromium-profile/ + +# SQLite runtime database +data/ diff --git a/package-lock.json b/package-lock.json index dee317d..969eb4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "rednote-danmu-bot", "version": "1.0.0", "dependencies": { + "better-sqlite3": "^12.10.0", "express": "^4.21.0", "playwright": "^1.49.0", "ws": "^8.18.0" @@ -1282,6 +1283,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.30", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", @@ -1295,6 +1316,40 @@ "node": ">=6.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -1387,6 +1442,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1500,6 +1579,12 @@ "node": ">=10" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -1672,6 +1757,21 @@ "ms": "2.0.0" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -1687,6 +1787,15 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1716,6 +1825,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -1792,6 +1910,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -1914,6 +2041,15 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -1994,6 +2130,12 @@ "bser": "2.1.1" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2057,6 +2199,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2168,6 +2316,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -2292,6 +2446,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -2340,6 +2514,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3370,6 +3550,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3383,12 +3575,33 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3405,6 +3618,30 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -3470,7 +3707,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -3705,6 +3941,33 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -3760,6 +4023,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -3816,6 +4089,30 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -3823,6 +4120,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4077,6 +4388,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -4144,6 +4500,15 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -4245,6 +4610,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -4289,6 +4682,18 @@ "node": ">=0.6" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -4372,6 +4777,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -4453,7 +4864,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/package.json b/package.json index 748d209..b4c4c87 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,10 @@ "test": "npx --node-options=\"--experimental-vm-modules\" jest" }, "dependencies": { + "better-sqlite3": "^12.10.0", "express": "^4.21.0", - "ws": "^8.18.0", - "playwright": "^1.49.0" + "playwright": "^1.49.0", + "ws": "^8.18.0" }, "devDependencies": { "jest": "^29.7.0" diff --git a/server/__tests__/database.test.js b/server/__tests__/database.test.js new file mode 100644 index 0000000..ce44bab --- /dev/null +++ b/server/__tests__/database.test.js @@ -0,0 +1,110 @@ +import { listKeywords, addKeyword, removeKeyword, toggleKeyword, logSend, getStats } from '../database.js'; + +// Helper: find a keyword by text +function findByText(list, text) { + return list.find(k => k.text === text); +} + +// Helper: find a keyword by id +function findById(list, id) { + return list.find(k => k.id === id); +} + +afterAll(() => { + // Clean up any test keywords left behind + const all = listKeywords(); + for (const kw of all) { + if (kw.text.startsWith('__test__')) { + removeKeyword(kw.id); + } + } +}); + +test('listKeywords returns default keywords (≥8) with id, text, enabled', () => { + const list = listKeywords(); + expect(list.length).toBeGreaterThanOrEqual(8); + for (const kw of list) { + expect(kw).toHaveProperty('id'); + expect(kw).toHaveProperty('text'); + expect(kw).toHaveProperty('enabled'); + } +}); + +test('addKeyword adds a new keyword and it appears in the list', () => { + const testText = '__test__add_' + Date.now(); + const updatedList = addKeyword(testText); + const found = findByText(updatedList, testText); + expect(found).toBeDefined(); + expect(found.text).toBe(testText); + expect(found.enabled).toBe(1); + + // Clean up + removeKeyword(found.id); +}); + +test('toggleKeyword toggles enabled status', () => { + // Add a fresh keyword to toggle + const testText = '__test__toggle_' + Date.now(); + const afterAdd = addKeyword(testText); + const kw = findByText(afterAdd, testText); + + // Initially enabled should be 1 + expect(kw.enabled).toBe(1); + + // Toggle → should become 0 + const afterToggle1 = toggleKeyword(kw.id); + const toggled1 = findById(afterToggle1, kw.id); + expect(toggled1.enabled).toBe(0); + + // Toggle again → should become 1 + const afterToggle2 = toggleKeyword(kw.id); + const toggled2 = findById(afterToggle2, kw.id); + expect(toggled2.enabled).toBe(1); + + // Clean up + removeKeyword(kw.id); +}); + +test('removeKeyword removes a keyword and it no longer appears', () => { + const testText = '__test__remove_' + Date.now(); + const afterAdd = addKeyword(testText); + const kw = findByText(afterAdd, testText); + expect(kw).toBeDefined(); + + const afterRemove = removeKeyword(kw.id); + const stillThere = findByText(afterRemove, testText); + expect(stillThere).toBeUndefined(); +}); + +test('getStats returns the correct structure', () => { + // Insert a test log entry so stats have at least some data + logSend('testPrefix', 'testKeyword', 'testPrefix testKeyword', 'success'); + + const stats = getStats(); + expect(stats).toHaveProperty('total'); + expect(typeof stats.total).toBe('number'); + expect(stats.total).toBeGreaterThanOrEqual(1); + + expect(stats).toHaveProperty('today'); + expect(typeof stats.today).toBe('number'); + + expect(stats).toHaveProperty('todaySuccess'); + expect(typeof stats.todaySuccess).toBe('number'); + + expect(stats).toHaveProperty('todayFail'); + expect(typeof stats.todayFail).toBe('number'); + + expect(stats).toHaveProperty('recentRate'); + expect(Array.isArray(stats.recentRate)).toBe(true); + if (stats.recentRate.length > 0) { + expect(stats.recentRate[0]).toHaveProperty('time'); + expect(stats.recentRate[0]).toHaveProperty('count'); + } + + expect(stats).toHaveProperty('dailyHistory'); + expect(Array.isArray(stats.dailyHistory)).toBe(true); + if (stats.dailyHistory.length > 0) { + expect(stats.dailyHistory[0]).toHaveProperty('date'); + expect(stats.dailyHistory[0]).toHaveProperty('count'); + } +}); diff --git a/server/database.js b/server/database.js new file mode 100644 index 0000000..4905688 --- /dev/null +++ b/server/database.js @@ -0,0 +1,102 @@ +import Database from 'better-sqlite3'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DB_DIR = path.resolve(__dirname, '..', 'data'); +const DB_PATH = path.join(DB_DIR, 'bot.db'); + +// ─── Initialize ──────────────────────────────────────────────────────────────── +fs.mkdirSync(DB_DIR, { recursive: true }); + +const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); + +db.exec(` + CREATE TABLE IF NOT EXISTS send_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prefix TEXT NOT NULL, + keyword TEXT, + full_message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'success', + error TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + + CREATE TABLE IF NOT EXISTS keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL UNIQUE, + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); +`); + +// ─── Default keywords ───────────────────────────────────────────────────────── +const DEFAULT_KEYWORDS = ['中!', '回家!', '冲冲冲', '来了来了', '可以可以', '太棒了', '可以啊', '不错不错']; + +const insertKeyword = db.prepare('INSERT OR IGNORE INTO keywords (text) VALUES (?)'); +for (const kw of DEFAULT_KEYWORDS) { + insertKeyword.run(kw); +} + +// ─── Prepared statements ────────────────────────────────────────────────────── +const stmtListKeywords = db.prepare('SELECT id, text, enabled FROM keywords ORDER BY id'); +const stmtAddKeyword = db.prepare('INSERT INTO keywords (text) VALUES (?)'); +const stmtRemoveKeyword = db.prepare('DELETE FROM keywords WHERE id = ?'); +const stmtToggleKeyword = db.prepare('UPDATE keywords SET enabled = CASE WHEN enabled = 1 THEN 0 ELSE 1 END WHERE id = ?'); +const stmtLogSend = db.prepare('INSERT INTO send_logs (prefix, keyword, full_message, status, error) VALUES (?, ?, ?, ?, ?)'); +const stmtTotal = db.prepare('SELECT COUNT(*) as total FROM send_logs'); +const stmtToday = db.prepare("SELECT COUNT(*) as count FROM send_logs WHERE date(created_at) = date('now','localtime')"); +const stmtTodaySuccess = db.prepare("SELECT COUNT(*) as count FROM send_logs WHERE date(created_at) = date('now','localtime') AND status = 'success'"); +const stmtTodayFail = db.prepare("SELECT COUNT(*) as count FROM send_logs WHERE date(created_at) = date('now','localtime') AND status != 'success'"); +const stmtRecentRate = db.prepare(` + SELECT strftime('%H:%M', created_at) as time, COUNT(*) as count + FROM send_logs + WHERE created_at >= datetime('now','localtime','-30 minutes') + GROUP BY strftime('%H:%M', created_at) + ORDER BY time +`); +const stmtDailyHistory = db.prepare(` + SELECT date(created_at) as date, COUNT(*) as count + FROM send_logs + WHERE created_at >= datetime('now','localtime','-7 days') + GROUP BY date(created_at) + ORDER BY date +`); + +// ─── Exported functions ─────────────────────────────────────────────────────── + +export function listKeywords() { + return stmtListKeywords.all(); +} + +export function addKeyword(text) { + stmtAddKeyword.run(text); + return listKeywords(); +} + +export function removeKeyword(id) { + stmtRemoveKeyword.run(id); + return listKeywords(); +} + +export function toggleKeyword(id) { + stmtToggleKeyword.run(id); + return listKeywords(); +} + +export function logSend(prefix, keyword, fullMessage, status = 'success', error = null) { + stmtLogSend.run(prefix, keyword, fullMessage, status, error); +} + +export function getStats() { + const total = stmtTotal.get().total; + const today = stmtToday.get().count; + const todaySuccess = stmtTodaySuccess.get().count; + const todayFail = stmtTodayFail.get().count; + const recentRate = stmtRecentRate.all(); + const dailyHistory = stmtDailyHistory.all(); + + return { total, today, todaySuccess, todayFail, recentRate, dailyHistory }; +} From dd3fdf41ddc86ca7516f55605562b604ed0bd556 Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Mon, 18 May 2026 21:02:57 +0800 Subject: [PATCH 03/15] feat: add intelligent message engine --- server/__tests__/messageEngine.test.js | 27 ++++++++++++++++++++++ server/messageEngine.js | 32 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 server/__tests__/messageEngine.test.js create mode 100644 server/messageEngine.js diff --git a/server/__tests__/messageEngine.test.js b/server/__tests__/messageEngine.test.js new file mode 100644 index 0000000..5891add --- /dev/null +++ b/server/__tests__/messageEngine.test.js @@ -0,0 +1,27 @@ +import { buildMessage } from '../messageEngine.js'; + +const PREFIX = '【测试】'; + +test('buildMessage returns message starting with prefix', () => { + const result = buildMessage(PREFIX); + expect(result.fullMessage.startsWith(PREFIX)).toBe(true); +}); + +test('buildMessage result contains the selected keyword', () => { + const result = buildMessage(PREFIX); + expect(result.fullMessage).toContain(result.keyword); +}); + +test('buildMessage appends symbols after keyword (length ≥ 2)', () => { + const result = buildMessage(PREFIX); + const symbolsPart = result.fullMessage.slice(PREFIX.length + 1 + result.keyword.length); + expect(symbolsPart.length).toBeGreaterThanOrEqual(2); +}); + +test('buildMessage random part contains no digits across 20 runs', () => { + for (let i = 0; i < 20; i++) { + const result = buildMessage(PREFIX); + const randomPart = result.fullMessage.slice(PREFIX.length + 1); + expect(randomPart).not.toMatch(/[0-9]/); + } +}); diff --git a/server/messageEngine.js b/server/messageEngine.js new file mode 100644 index 0000000..c820f8c --- /dev/null +++ b/server/messageEngine.js @@ -0,0 +1,32 @@ +import { listKeywords, logSend } from './database.js'; +import { generate } from './randomizer.js'; + +export function buildMessage(prefix) { + // 1. 获取所有启用的关键词 + const keywords = listKeywords().filter(k => k.enabled); + + if (keywords.length === 0) { + // Fallback: 没有启用关键词时用随机字符串 + const randomStr = generate({ minLen: 3, maxLen: 6 }); + const fullMessage = prefix + ' ' + randomStr; + logSend(prefix, '(random)', fullMessage, 'success'); + return { fullMessage, keyword: '(random)' }; + } + + // 2. 随机选一个关键词 + const keyword = keywords[Math.floor(Math.random() * keywords.length)].text; + + // 3. 生成 2-5 个随机符号(symbols + emojis,不含数字和字母) + const symbols = generate({ + useLetters: false, + useSymbols: true, + useEmojis: true, + minLen: 2, + maxLen: 5, + }); + + // 4. 组装消息 + const fullMessage = prefix + ' ' + keyword + symbols; + logSend(prefix, keyword, fullMessage, 'success'); + return { fullMessage, keyword }; +} From 9ca4d218eef3fc797465c3a2389a95981a68a3a6 Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Mon, 18 May 2026 21:03:56 +0800 Subject: [PATCH 04/15] feat: extend WebSocket protocol, integrate messageEngine --- server/bot.js | 68 +++++++++++++------------------------------------ server/index.js | 34 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/server/bot.js b/server/bot.js index add7cde..0ea7ea7 100644 --- a/server/bot.js +++ b/server/bot.js @@ -1,10 +1,12 @@ import { chromium } from 'playwright'; import { generate, applyTemplate } from './randomizer.js'; +import { buildMessage } from './messageEngine.js'; import config from './config.js'; class DanmuBot { constructor() { this.browser = null; + this.context = null; this.page = null; this.running = false; this._stopRequested = false; @@ -15,53 +17,23 @@ class DanmuBot { } async connect(url) { - this.browser = await chromium.launch({ headless: config.headless }); - const context = await this.browser.newContext({ + // 使用持久化用户数据目录,登录态会自动保存,下次免扫码 + this.context = await chromium.launchPersistentContext(config.userDataDir, { + headless: config.headless, + channel: 'chrome', viewport: { width: 1280, height: 720 }, }); - this.page = await context.newPage(); - await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); - - // Try multiple selectors to find the input box - const inputSelectors = [ - 'input[placeholder*="说点什么"]', - 'input[placeholder*="弹幕"]', - 'input[placeholder*="评论"]', - 'textarea[placeholder*="说点什么"]', - '[contenteditable="true"]', - '.input-area input', - '.chat-input input', - ]; - - let found = false; - for (const selector of inputSelectors) { - try { - await this.page.waitForSelector(selector, { timeout: 5000 }); - this._log('info', `Connected — input found with selector: ${selector}`); - found = true; - return; - } catch { - // Try next selector - } - } - - // Fallback to generic input or textarea - if (!found) { - try { - await this.page.waitForSelector('input', { timeout: 5000 }); - this._log('info', 'Connected — input found with generic selector: input'); - } catch { - await this.page.waitForSelector('textarea', { timeout: 5000 }); - this._log('info', 'Connected — input found with generic selector: textarea'); - } - } + const pages = this.context.pages(); + this.page = pages.length > 0 ? pages[0] : await this.context.newPage(); + await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); + this._log('info', `浏览器已打开,请在浏览器窗口中登录小红书`); } async disconnect() { await this.stop(); - if (this.browser) { - await this.browser.close(); - this.browser = null; + if (this.context) { + await this.context.close(); + this.context = null; this.page = null; } } @@ -76,22 +48,16 @@ class DanmuBot { while (!this._stopRequested) { try { - const randomStr = generate(options.random || {}); - let message; - if (template) { - message = applyTemplate(template, prefix, randomStr); - } else { - message = prefix + ' ' + randomStr; - } + const { fullMessage } = buildMessage(prefix); - await this._sendMessage(message); + await this._sendMessage(fullMessage); this.sentCount++; if (this.onCount) { this.onCount(this.sentCount); } - this._log('info', `Message sent: "${message}"`); + this._log('info', `Message sent: "${fullMessage}"`); } catch (err) { if (this.onError) { this.onError(err); @@ -142,7 +108,7 @@ class DanmuBot { } if (!inputEl) { - throw new Error('Cannot find input element on the page'); + throw new Error('找不到输入框 — 请确认已在浏览器中登录小红书并进入直播间'); } await inputEl.click(); diff --git a/server/index.js b/server/index.js index af4f2fa..946aa23 100644 --- a/server/index.js +++ b/server/index.js @@ -135,6 +135,40 @@ wss.on('connection', (ws) => { break; } + case 'listKeywords': { + const { listKeywords } = await import('./database.js'); + ws.send(JSON.stringify({ type: 'keywords', list: listKeywords() })); + break; + } + + case 'addKeyword': { + const { addKeyword } = await import('./database.js'); + if (!parsed.text || !parsed.text.trim()) { + ws.send(JSON.stringify({ type: 'error', message: '关键词不能为空' })); + return; + } + ws.send(JSON.stringify({ type: 'keywords', list: addKeyword(parsed.text) })); + break; + } + + case 'removeKeyword': { + const { removeKeyword } = await import('./database.js'); + ws.send(JSON.stringify({ type: 'keywords', list: removeKeyword(parsed.id) })); + break; + } + + case 'toggleKeyword': { + const { toggleKeyword } = await import('./database.js'); + ws.send(JSON.stringify({ type: 'keywords', list: toggleKeyword(parsed.id) })); + break; + } + + case 'getStats': { + const { getStats } = await import('./database.js'); + ws.send(JSON.stringify({ type: 'stats', ...getStats() })); + break; + } + case 'getStatus': { let state = 'disconnected'; if (bot.page) state = 'connected'; From 4ca1242d4f133fed7f34e7d82139e25015b1cdd7 Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Mon, 18 May 2026 21:07:12 +0800 Subject: [PATCH 05/15] feat: add keyword management component --- client/src/components/KeywordManager.vue | 128 +++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 client/src/components/KeywordManager.vue diff --git a/client/src/components/KeywordManager.vue b/client/src/components/KeywordManager.vue new file mode 100644 index 0000000..7ff8d0a --- /dev/null +++ b/client/src/components/KeywordManager.vue @@ -0,0 +1,128 @@ + + + + + From 33bd64223624919f653dd56745a67f5b9af2b737 Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Mon, 18 May 2026 21:11:11 +0800 Subject: [PATCH 06/15] feat: add analytics dashboard with charts --- client/package-lock.json | 19 ++ client/package.json | 1 + client/src/components/AnalyticsPanel.vue | 285 +++++++++++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 client/src/components/AnalyticsPanel.vue diff --git a/client/package-lock.json b/client/package-lock.json index f6be27b..9fc5093 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,7 @@ "name": "rednote-danmu-bot-client", "version": "1.0.0", "dependencies": { + "chart.js": "^4.5.1", "vue": "^3.5.0" }, "devDependencies": { @@ -509,6 +510,12 @@ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", @@ -1019,6 +1026,18 @@ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", diff --git a/client/package.json b/client/package.json index b6989a8..14935d2 100644 --- a/client/package.json +++ b/client/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "chart.js": "^4.5.1", "vue": "^3.5.0" }, "devDependencies": { diff --git a/client/src/components/AnalyticsPanel.vue b/client/src/components/AnalyticsPanel.vue new file mode 100644 index 0000000..ebfbe41 --- /dev/null +++ b/client/src/components/AnalyticsPanel.vue @@ -0,0 +1,285 @@ + + + + + From 3ac21ae392a6c4680a81b7b1e981f6d0e0d329d5 Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Mon, 18 May 2026 21:12:35 +0800 Subject: [PATCH 07/15] feat: add tab navigation for control/keywords/analytics --- client/src/App.vue | 403 +++++++++++++++++++++++---------------------- 1 file changed, 208 insertions(+), 195 deletions(-) diff --git a/client/src/App.vue b/client/src/App.vue index 944da56..dd54007 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,268 +1,281 @@ From df35228d05bad6ae32fa4bb68fe583f832b80c2d Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Mon, 18 May 2026 21:15:17 +0800 Subject: [PATCH 08/15] fix: make ws message handler async for dynamic imports --- server/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/index.js b/server/index.js index 946aa23..e83bed7 100644 --- a/server/index.js +++ b/server/index.js @@ -75,7 +75,7 @@ wss.on('connection', (ws) => { ws.send(JSON.stringify({ type: 'status', state: 'disconnected', sentCount: 0 })); // ── Handle incoming messages ── - ws.on('message', (data) => { + ws.on('message', async (data) => { let parsed; try { parsed = JSON.parse(data.toString()); From 36a6fade8e2ddbd09d1510d2595b9ee0990efdce Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Mon, 18 May 2026 21:31:27 +0800 Subject: [PATCH 09/15] docs: update README for v2, add dove logo to title --- README.md | 85 ++++++++++++++++++++++++++++++----------------- client/index.html | 2 +- 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 0e84503..4c2025c 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,19 @@ ## 功能 -- **Web 控制面板**:浏览器操作界面,实时控制 -- **自动发弹幕**:用户指定前缀 + 随机字符串(防吞弹幕) -- **频率控制**:可调发送间隔 + 随机抖动(防风控) -- **随机字符串**:字母、符号、表情混搭,不含数字,用户可自定义字符集 -- **实时日志**:发送状态、计数、错误信息一目了然 +- **🎮 三面板控制**:控制 / 关键词 / 数据,标签页切换 +- **🧠 智能消息引擎**:`前缀 + 关键词 + 随机符号` 组合,每次不同,防检测 +- **📝 关键词管理**:可增删改关键词,支持 iOS 开关启用/禁用 +- **📊 数据看板**:实时速率折线图 + 7 日柱状图 + 统计卡片 +- **💾 数据持久化**:SQLite 存储发送记录,重启不丢失 +- **🎯 频率控制**:可调发送间隔 + 随机抖动(防风控) +- **📋 实时日志**:发送状态、计数、错误信息一目了然 +- **🔄 免扫码**:持久化浏览器会话,一次登录长期使用 ## 快速开始 ```bash -# 1. 安装后端依赖 +# 1. 安装依赖 npm install # 2. 安装前端依赖并构建 @@ -27,13 +30,22 @@ npm run server ## 使用说明 -1. **启动服务**后,浏览器打开控制面板 -2. 在小红书直播间页面**扫码登录**(需要先在浏览器登录小红书) -3. 复制直播间 URL 粘贴到控制面板 → 点击「连接」 -4. Playwright 会自动打开一个浏览器窗口进入直播间,在**该窗口**中完成扫码登录(如有需要) -5. 设置消息前缀、发送间隔、随机串配置 -6. 点击「开始发送」 -7. 点击「停止」结束发送 +1. **启动服务**,浏览器打开控制面板 +2. 在 Playwright 打开的 Chrome 窗口中登录小红书 +3. 复制直播间 URL → 点击「连接」 +4. 在 **关键词** 页面设置你想发的词句(如"中!"、"冲冲冲") +5. 回到 **控制** 页面,输入前缀(如"76"),点击「开始发送」 +6. 切换到 **数据** 页面查看实时发送统计 + +### 消息构成 + +``` +用户前缀 + 关键词(随机选) + 随机符号/表情 + "76" "中!" "~!@😊" + "76" "冲冲冲" "🔥✨" + +→ 每次发送结果都不同,防止被吞 +``` ## 配置 @@ -47,8 +59,7 @@ npm run server | `maxInterval` | 8000 | 最大发送间隔 (ms) | | `jitterRatio` | 0.3 | 间隔抖动比例 (±30%) | | `headless` | false | 是否无头浏览器模式 | -| `randomLengthMin` | 3 | 随机串最小长度 | -| `randomLengthMax` | 6 | 随机串最大长度 | +| `userDataDir` | `.chromium-profile` | 浏览器用户数据目录 | ## 开发 @@ -56,8 +67,8 @@ npm run server # 前端热更新开发 cd client && npx vite -# 运行测试 -npx jest server/__tests__/randomizer.test.js +# 运行全部测试 +npx --node-options="--experimental-vm-modules" jest # 构建前端 cd client && npx vite build @@ -68,21 +79,25 @@ cd client && npx vite build ``` RedNoteDanmuBot/ ├── server/ -│ ├── index.js # Express + WebSocket 服务 -│ ├── bot.js # Playwright 弹幕发送核心 -│ ├── randomizer.js # 随机字符串生成 -│ ├── config.js # 默认配置 -│ └── __tests__/ -│ └── randomizer.test.js +│ ├── index.js # Express + WebSocket 服务 +│ ├── bot.js # Playwright 弹幕发送核心 +│ ├── messageEngine.js # 智能消息引擎 +│ ├── database.js # SQLite 持久化 +│ ├── randomizer.js # 随机字符串生成 +│ ├── config.js # 默认配置 +│ └── __tests__/ # 13 个测试 ├── client/ │ ├── src/ -│ │ ├── App.vue # 主控制面板 +│ │ ├── App.vue # 主控制面板 │ │ ├── main.js -│ │ └── style.css +│ │ ├── style.css +│ │ └── components/ +│ │ ├── KeywordManager.vue # 关键词管理 +│ │ └── AnalyticsPanel.vue # 数据看板 │ ├── index.html │ └── vite.config.js -├── docs/ -│ └── superpowers/ +├── data/ # SQLite 数据库(自动创建) +├── .chromium-profile/ # 浏览器会话缓存 ├── package.json └── README.md ``` @@ -93,10 +108,20 @@ RedNoteDanmuBot/ |----|------| | 后端 | Node.js + Express + WebSocket | | 浏览器自动化 | Playwright | -| 前端 | Vue 3 + Vite | +| 前端 | Vue 3 + Chart.js + Vite | +| 存储 | SQLite (better-sqlite3) | + +## 测试 + +```bash +npx --node-options="--experimental-vm-modules" jest +``` + +13 个测试覆盖:randomizer / database / messageEngine ## 注意事项 - 请合理使用,避免违反小红书社区规则 -- 发送频率不宜过快,建议 2-3 秒以上间隔 -- 首次使用需要在小红书直播间完成扫码登录 +- 发送频率建议 2-3 秒以上间隔 +- 首次使用需要在 Playwright 打开的浏览器中扫码登录 +- 登录态会保存在 `.chromium-profile/` 目录,下次免扫码 diff --git a/client/index.html b/client/index.html index e8b934f..10efd93 100644 --- a/client/index.html +++ b/client/index.html @@ -3,7 +3,7 @@ - RedNoteDanmuBot + 🕊️ RedNoteDanmuBot
From 83fa0f5cc251af2fd2ee7191f1fc8ff91ace1759 Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Mon, 18 May 2026 21:31:43 +0800 Subject: [PATCH 10/15] chore: UI polish - dove logo, toggle switch, left-aligned inputs, card layout --- client/src/App.vue | 193 ++++---- client/src/components/AnalyticsPanel.vue | 32 +- client/src/components/KeywordManager.vue | 145 +++--- client/src/style.css | 575 +++++++++++++---------- server/config.js | 3 + 5 files changed, 502 insertions(+), 446 deletions(-) diff --git a/client/src/App.vue b/client/src/App.vue index dd54007..35b985a 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,5 +1,5 @@ diff --git a/client/src/style.css b/client/src/style.css index 2bd6c48..334931b 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -1,338 +1,413 @@ -/* ─── Reset & base ─────────────────────────────────────────────────── */ -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} +/* ─── RedNoteDanmuBot — Design System v2 ──────────────────────────── + * Style: Dashboard Grid Layout + Neutral Color Scheme + * ──────────────────────────────────────────────────────────────────── */ -:root { - --primary: #ff2442; - --primary-hov:#d91e37; - --primary-bg: #fff5f6; - --bg: #f5f5f5; - --card-bg: #ffffff; - --text: #1a1a1a; - --text-dim: #888888; - --border: #e8e8e8; - --radius: 12px; - --shadow: 0 2px 12px rgba(0,0,0,0.06); - --green: #34c759; - --orange: #ff9500; - --red: var(--primary); - --gray: #c7c7cc; - --log-bg: #1e1e2e; - --log-text: #cdd6f4; - --log-info: #89b4fa; - --log-error: #f38ba8; -} - -html { - font-size: 15px; -} +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); +:root { + /* Accent — 仅用于按钮,不用于文字 */ + --accent: #007aff; + --accent-hover: #0056cc; + --green: #34c759; + --green-hover: #2db84e; + --red: #ff3b30; + --red-hover: #d63031; + + /* Neutrals */ + --bg: #f2f2f7; + --surface: #ffffff; + --surface-hover:#f8f8fa; + --text: #1d1d1f; + --text-secondary:#6e6e73; + --text-tertiary: #aeaeb2; + --separator: rgba(60, 60, 67, 0.08); + + /* Log */ + --log-bg: #1d1d1f; + --log-text: #f5f5f7; + + /* Spacing */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + + /* Radii */ + --radius-sm: 8px; + --radius-md: 12px; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0,0,0,0.04); + --shadow-md: 0 4px 20px rgba(0,0,0,0.06); + + --font: 'Inter', -apple-system, 'SF Pro Text', 'Helvetica Neue', 'Noto Sans SC', sans-serif; + --font-mono: 'SF Mono', 'SF Pro Text', 'Fira Code', monospace; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { font-size: 16px; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans SC', sans-serif; + font-family: var(--font); background: var(--bg); color: var(--text); + -webkit-font-smoothing: antialiased; min-height: 100vh; - padding: 24px; } #app { - max-width: 720px; + max-width: 960px; margin: 0 auto; + padding: var(--space-lg) var(--space-md) 60px; display: flex; flex-direction: column; - gap: 16px; + gap: var(--space-md); } /* ─── Header ──────────────────────────────────────────────────────── */ -.header { - text-align: center; - padding: 20px 0 8px; +.app-header { + padding: 16px 4px 4px; + display: flex; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; } - -.header h1 { - font-size: 1.6rem; +.app-header h1 { + font-size: 22px; font-weight: 700; - color: var(--primary); - letter-spacing: 0.5px; + color: var(--text); + letter-spacing: -0.3px; +} +.app-header .subtitle { + font-size: 14px; + color: var(--text-tertiary); + font-weight: 400; } -.header p { - font-size: 0.85rem; - color: var(--text-dim); - margin-top: 4px; +/* ─── Tab Bar ─────────────────────────────────────────────────────── */ +.tab-bar { + display: flex; + background: var(--surface); + border-radius: var(--radius-md); + padding: 3px; + box-shadow: var(--shadow-sm); + border: 0.5px solid var(--separator); + gap: 2px; +} +.tab-btn { + flex: 1; + padding: 10px 0; + border: none; + border-radius: var(--radius-sm); + background: transparent; + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + font-family: var(--font); + transition: all 0.2s; + -webkit-tap-highlight-color: transparent; +} +.tab-btn:hover { color: var(--text); } +.tab-btn.active { + background: var(--accent); + color: white; + font-weight: 600; +} + +/* ─── Dashboard Grid ──────────────────────────────────────────────── */ +.dash-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: var(--space-md); +} +.dash-col { + display: flex; + flex-direction: column; + gap: var(--space-md); } -/* ─── Cards ────────────────────────────────────────────────────────── */ +@media (max-width: 860px) { + .dash-grid { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 540px) { + .dash-grid { grid-template-columns: 1fr; } +} + +/* ─── Cards ───────────────────────────────────────────────────────── */ .card { - background: var(--card-bg); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 20px 24px; - border: 1px solid var(--border); + background: var(--surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + border: 0.5px solid var(--separator); + overflow: hidden; } -.card-title { - font-size: 0.85rem; +.card-header { + padding: 14px 16px 8px; + font-size: 12px; font-weight: 600; - color: var(--text-dim); + color: var(--text-secondary); text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 14px; + letter-spacing: 0.4px; +} + +.card-body { + padding: 0 16px 14px; } -/* ─── Status indicator ────────────────────────────────────────────── */ -.status-row { +.card-row { display: flex; align-items: center; + min-height: 40px; gap: 10px; - margin-bottom: 14px; + border-bottom: 0.5px solid var(--separator); + padding: 6px 0; +} +.card-row:last-child { + border-bottom: none; } +/* ─── Status ──────────────────────────────────────────────────────── */ +.status-line { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: 14px; + font-weight: 500; + padding: 4px 0; +} .status-dot { - width: 10px; - height: 10px; + width: 8px; + height: 8px; border-radius: 50%; flex-shrink: 0; - animation: pulse 2s infinite; -} - -.status-dot.disconnected { - background: var(--gray); - animation: none; -} -.status-dot.connected { - background: var(--green); -} -.status-dot.running { - background: var(--orange); - animation: pulse 1s infinite; -} -.status-dot.idle { - background: var(--green); - animation: none; -} - -.status-label { - font-size: 0.9rem; - font-weight: 500; } - -.status-label.disconnected { color: var(--gray); } -.status-label.connected { color: var(--green); } -.status-label.running { color: var(--orange); } -.status-label.idle { color: var(--green); } - +.status-dot.disconnected { background: #c7c7cc; } +.status-dot.connected { background: var(--green); } +.status-dot.running { background: #ff9500; animation: pulse 1s infinite; } +.status-dot.idle { background: var(--green); } @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(1.3); } + 50% { opacity: 0.5; transform: scale(1.4); } } -/* ─── Form elements ───────────────────────────────────────────────── */ -.input-group { - display: flex; - gap: 10px; - margin-bottom: 12px; -} - -.input-group:last-child { - margin-bottom: 0; +.status-url { + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 280px; } -.input-group label { - font-size: 0.85rem; - font-weight: 500; - color: var(--text-dim); - min-width: 70px; +/* ─── Inputs ──────────────────────────────────────────────────────── */ +.input-row { display: flex; align-items: center; + gap: var(--space-sm); +} +.input-label { + font-size: 13px; + color: var(--text-secondary); flex-shrink: 0; + min-width: 40px; } - -.input-group input[type="text"], -.input-group input[type="number"] { +.input-field { flex: 1; - padding: 8px 12px; - border: 1px solid var(--border); - border-radius: 8px; - font-size: 0.9rem; + border: none; + background: transparent; + font-size: 14px; + color: var(--text); + text-align: left; outline: none; - transition: border-color 0.2s; -} - -.input-group input[type="text"]:focus, -.input-group input[type="number"]:focus { - border-color: var(--primary); + padding: 4px 0; + font-family: var(--font); + min-width: 0; } - -.input-group input[type="number"] { - max-width: 100px; +.input-field.right { + text-align: right; } - -.input-group input[type="text"] { - min-width: 0; +.input-field:focus { color: var(--accent); } +.input-field::placeholder { color: var(--text-tertiary); } +.input-field.num { max-width: 70px; text-align: center; } +.input-field:disabled { opacity: 0.35; } +.input-hint { + font-size: 12px; + color: var(--text-tertiary); + flex-shrink: 0; } -/* ─── Checkbox row ────────────────────────────────────────────────── */ -.checkbox-row { +/* ─── Buttons ─────────────────────────────────────────────────────── */ +.btn-row { display: flex; - gap: 18px; - flex-wrap: wrap; + gap: var(--space-sm); + padding: 4px 0; } - -.checkbox-row label { - display: flex; +.btn { + display: inline-flex; align-items: center; + justify-content: center; gap: 6px; - font-size: 0.85rem; - cursor: pointer; - user-select: none; -} - -.checkbox-row input[type="checkbox"] { - accent-color: var(--primary); - width: 16px; - height: 16px; - cursor: pointer; -} - -/* ─── Buttons ─────────────────────────────────────────────────────── */ -.btn { - padding: 8px 20px; + height: 36px; + padding: 0 18px; border: none; - border-radius: 8px; - font-size: 0.9rem; + border-radius: var(--radius-sm); + font-size: 14px; font-weight: 600; + font-family: var(--font); cursor: pointer; - transition: background 0.2s, opacity 0.2s; + transition: opacity 0.15s, transform 0.1s; + -webkit-tap-highlight-color: transparent; + outline: none; white-space: nowrap; } - -.btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.btn-primary { - background: var(--primary); - color: #fff; -} -.btn-primary:hover:not(:disabled) { - background: var(--primary-hov); -} - -.btn-secondary { - background: #f0f0f0; - color: var(--text); -} -.btn-secondary:hover:not(:disabled) { - background: #e0e0e0; -} - -.btn-success { - background: var(--green); - color: #fff; -} -.btn-success:hover:not(:disabled) { - background: #2db84e; -} - -.btn-danger { - background: var(--orange); - color: #fff; -} -.btn-danger:hover:not(:disabled) { - background: #e08600; +.btn:active:not(:disabled) { transform: scale(0.97); } +.btn:disabled { opacity: 0.3; cursor: not-allowed; transform: none; } +.btn-primary { background: var(--accent); color: white; } +.btn-primary:hover:not(:disabled) { background: var(--accent-hover); } +.btn-secondary { background: #e5e5ea; color: var(--text); } +.btn-secondary:hover:not(:disabled) { background: #d9d9de; } +.btn-success { background: var(--green); color: white; } +.btn-success:hover:not(:disabled) { background: var(--green-hover); } +.btn-danger { background: var(--red); color: white; } +.btn-danger:hover:not(:disabled) { background: var(--red-hover); } + +.btn-lg { + height: 44px; + font-size: 15px; + padding: 0 28px; } -.btn-group { - display: flex; - gap: 10px; - flex-wrap: wrap; -} +.btn-block { width: 100%; } +.btn-half { flex: 1; } -/* ─── Count display ───────────────────────────────────────────────── */ -.count-display { +/* ─── Count Display ───────────────────────────────────────────────── */ +.count-box { text-align: center; - padding: 8px 0 16px; + padding: 8px 0; } - .count-number { - font-size: 3rem; + font-size: 48px; font-weight: 800; - color: var(--primary); + color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; + letter-spacing: -1.5px; + transition: transform 0.15s; } - +.count-number.bump { transform: scale(1.08); } .count-label { - font-size: 0.8rem; - color: var(--text-dim); - margin-top: 4px; + font-size: 12px; + color: var(--text-tertiary); + margin-top: 2px; + font-weight: 500; +} + +/* ─── Error Banner ────────────────────────────────────────────────── */ +.error-banner { + background: rgba(255, 59, 48, 0.08); + border-radius: var(--radius-sm); + padding: 8px 12px; + font-size: 13px; + color: var(--red); + display: flex; + align-items: center; + gap: var(--space-sm); } -/* ─── Log area ────────────────────────────────────────────────────── */ +/* ─── Log ─────────────────────────────────────────────────────────── */ .log-area { background: var(--log-bg); - border-radius: 10px; - padding: 14px 16px; - height: 260px; + border-radius: var(--radius-sm); + padding: 12px 14px; + height: 220px; overflow-y: auto; - font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; - font-size: 0.78rem; - line-height: 1.6; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.7; } +.log-area::-webkit-scrollbar { width: 4px; } +.log-area::-webkit-scrollbar-track { background: transparent; } +.log-area::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; } +.log-entry { white-space: pre-wrap; word-break: break-all; } +.log-time { color: #636366; margin-right: 8px; user-select: none; } -.log-area::-webkit-scrollbar { - width: 6px; -} -.log-area::-webkit-scrollbar-track { - background: transparent; -} -.log-area::-webkit-scrollbar-thumb { - background: #45475a; - border-radius: 3px; -} +.log-level-info .log-tag, +.log-level-info .log-msg { color: #64d2ff; } +.log-level-error .log-tag, +.log-level-error .log-msg { color: #ff453a; } +.log-level-success .log-tag, +.log-level-success .log-msg { color: #30d158; } -.log-entry { - white-space: pre-wrap; - word-break: break-all; -} +.log-msg { color: var(--log-text); } -.log-time { - color: #6c7086; - margin-right: 8px; +.log-empty { + color: #636366; + text-align: center; + padding-top: 80px; + font-size: 13px; +} + +/* ─── Sent Flash ──────────────────────────────────────────────────── */ +@keyframes checkPop { + 0% { transform: scale(0); opacity: 0; } + 50% { transform: scale(1.2); opacity: 1; } + 100% { transform: scale(1); opacity: 0; } +} +.sent-flash { + position: fixed; top: 50%; left: 50%; + font-size: 40px; + pointer-events: none; + animation: checkPop 0.35s ease forwards; + z-index: 100; +} + +/* ─── Toggle Switch ───────────────────────────────────────────────── */ +.toggle { + position: relative; + display: inline-block; + width: 42px; + height: 26px; + cursor: pointer; + flex-shrink: 0; } - -.log-level-info { - color: var(--log-info); +.toggle input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} +.toggle-slider { + position: absolute; + inset: 0; + background: #e5e5ea; + border-radius: 13px; + transition: background 0.2s; +} +.toggle-slider::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 10px; + background: white; + box-shadow: 0 1px 3px rgba(0,0,0,0.15); + transition: transform 0.2s; } - -.log-level-error { - color: var(--log-error); +.toggle input:checked + .toggle-slider { + background: var(--green); } - -.log-message { - color: var(--log-text); +.toggle input:checked + .toggle-slider::after { + transform: translateX(16px); } -.log-empty { - color: #6c7086; - text-align: center; - padding-top: 100px; -} - -/* ─── Responsive ──────────────────────────────────────────────────── */ -@media (max-width: 500px) { - body { padding: 12px; } - .card { padding: 16px; } - .input-group { flex-direction: column; gap: 4px; } - .input-group label { min-width: auto; } - .input-group input[type="number"] { max-width: 100%; } - .btn-group { flex-direction: column; } - .btn { width: 100%; text-align: center; } +/* ─── Reduced Motion ──────────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } + .status-dot.running { animation: none; } } diff --git a/server/config.js b/server/config.js index 140b616..165c988 100644 --- a/server/config.js +++ b/server/config.js @@ -9,4 +9,7 @@ export default { headless: false, retryCount: 1, maxConsecutiveErrors: 3, + + // 浏览器用户数据目录(用于持久化登录态,避免重复扫码) + userDataDir: '.chromium-profile', }; From d9eba276cc1f943525a657ad79161b61f5013257 Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Mon, 18 May 2026 21:39:59 +0800 Subject: [PATCH 11/15] feat: add multi-browser support (Chrome/Edge/Safari) --- client/src/App.vue | 16 +++++++++++++--- client/src/style.css | 26 ++++++++++++++++++++++++++ server/bot.js | 37 ++++++++++++++++++++++++++++++------- server/config.js | 3 +++ server/index.js | 2 +- 5 files changed, 73 insertions(+), 11 deletions(-) diff --git a/client/src/App.vue b/client/src/App.vue index 35b985a..a28ae03 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -5,6 +5,7 @@ import AnalyticsPanel from './components/AnalyticsPanel.vue'; // ─── State ────────────────────────────────────────────────────────────────────── const url = ref(''); +const browser = ref('chrome'); const prefix = ref('76'); const interval = ref(2500); const activeTab = ref('control'); @@ -89,7 +90,7 @@ function send(cmd) { function connect() { if (!url.value.trim()) return; errorMsg.value = ''; - send({ type: 'connect', url: url.value.trim() }); + send({ type: 'connect', url: url.value.trim(), browser: browser.value }); } function disconnect() { errorMsg.value = ''; @@ -155,15 +156,24 @@ function fmtTime(iso) { {{ statusLabel }}
{{ url || '未指定直播间' }}
-
+
+
diff --git a/client/src/style.css b/client/src/style.css index 334931b..49e703f 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -315,6 +315,32 @@ body { gap: var(--space-sm); } +/* ─── Browser Select ─────────────────────────────────────────────── */ +.browser-select { + appearance: none; + -webkit-appearance: none; + padding: 8px 28px 8px 10px; + border: 0.5px solid var(--separator); + border-radius: 6px; + background: var(--surface); + font-size: 13px; + font-family: var(--font); + color: var(--text); + cursor: pointer; + outline: none; + min-width: 80px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236e6e73'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; +} +.browser-select:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.browser-select:focus { + border-color: var(--accent); +} + /* ─── Log ─────────────────────────────────────────────────────────── */ .log-area { background: var(--log-bg); diff --git a/server/bot.js b/server/bot.js index 0ea7ea7..b4fb9f4 100644 --- a/server/bot.js +++ b/server/bot.js @@ -1,4 +1,4 @@ -import { chromium } from 'playwright'; +import { chromium, webkit } from 'playwright'; import { generate, applyTemplate } from './randomizer.js'; import { buildMessage } from './messageEngine.js'; import config from './config.js'; @@ -16,17 +16,40 @@ class DanmuBot { this.onCount = null; } - async connect(url) { - // 使用持久化用户数据目录,登录态会自动保存,下次免扫码 - this.context = await chromium.launchPersistentContext(config.userDataDir, { + async connect(url, browserType) { + browserType = browserType || config.browser; + + const launchOptions = { headless: config.headless, - channel: 'chrome', viewport: { width: 1280, height: 720 }, - }); + }; + + switch (browserType) { + case 'edge': + launchOptions.channel = 'msedge'; + this.context = await chromium.launchPersistentContext(config.userDataDir, launchOptions); + break; + case 'safari': + this.context = await webkit.launchPersistentContext(config.userDataDir, { + ...launchOptions, + // Safari WebKit 使用 Playwright 自带的 WebKit 构建 + }); + break; + case 'chromium': + // Playwright 自带的 Chromium(不需要系统安装) + this.context = await chromium.launchPersistentContext(config.userDataDir, launchOptions); + break; + default: + // 'chrome' — 使用系统安装的 Chrome + launchOptions.channel = 'chrome'; + this.context = await chromium.launchPersistentContext(config.userDataDir, launchOptions); + break; + } + const pages = this.context.pages(); this.page = pages.length > 0 ? pages[0] : await this.context.newPage(); await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); - this._log('info', `浏览器已打开,请在浏览器窗口中登录小红书`); + this._log('info', `[${browserType}] 浏览器已打开,请在浏览器窗口中登录小红书`); } async disconnect() { diff --git a/server/config.js b/server/config.js index 165c988..fa0cb9a 100644 --- a/server/config.js +++ b/server/config.js @@ -12,4 +12,7 @@ export default { // 浏览器用户数据目录(用于持久化登录态,避免重复扫码) userDataDir: '.chromium-profile', + + // 浏览器类型: 'chrome' | 'edge' | 'chromium' | 'firefox' + browser: 'chrome', }; diff --git a/server/index.js b/server/index.js index e83bed7..7e9c869 100644 --- a/server/index.js +++ b/server/index.js @@ -92,7 +92,7 @@ wss.on('connection', (ws) => { ws.send(JSON.stringify({ type: 'error', message: 'Missing url in connect message' })); return; } - bot.connect(url) + bot.connect(url, parsed.browser) .then(() => { ws.send(JSON.stringify({ type: 'status', state: 'connected', sentCount: bot.sentCount })); }) From 6d0903608550c53aee1855fa68d393e0ccf7b5b3 Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Thu, 21 May 2026 15:46:21 +0800 Subject: [PATCH 12/15] docs: comprehensive README with macOS support, fix Safari comment --- README.md | 190 +++++++++++++++++++++++++------------------ client/src/App.vue | 64 +++++++++++++-- client/src/style.css | 32 ++++++++ server/bot.js | 23 ++++-- 4 files changed, 220 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 4c2025c..6e133a0 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,161 @@ # 🕊️ RedNoteDanmuBot -小红书直播间弹幕机器人 — 通过 Web 控制面板自动发送弹幕。 +小红书直播间弹幕机器人 — Web 控制面板自动发送弹幕,支持 Chrome / Edge / Safari。 -## 功能 +## 功能一览 -- **🎮 三面板控制**:控制 / 关键词 / 数据,标签页切换 -- **🧠 智能消息引擎**:`前缀 + 关键词 + 随机符号` 组合,每次不同,防检测 -- **📝 关键词管理**:可增删改关键词,支持 iOS 开关启用/禁用 -- **📊 数据看板**:实时速率折线图 + 7 日柱状图 + 统计卡片 -- **💾 数据持久化**:SQLite 存储发送记录,重启不丢失 -- **🎯 频率控制**:可调发送间隔 + 随机抖动(防风控) -- **📋 实时日志**:发送状态、计数、错误信息一目了然 -- **🔄 免扫码**:持久化浏览器会话,一次登录长期使用 +| 功能 | 说明 | +|------|------| +| 🎮 **三面板控制** | 控制 / 关键词 / 数据,标签页切换 | +| 🧠 **双模式消息** | 关键词模式(前缀+词+符号)或全随机模式 | +| 📝 **关键词管理** | 增删改关键词,iOS 开关启用/禁用 | +| 📊 **数据看板** | 实时折线图 + 7 日柱状图 + 4 张统计卡 | +| 💾 **自动持久化** | SQLite 存储发送记录,重启不丢 | +| 🎯 **智能防检测** | 随机抖动 ±30%、每次消息不同、真人化 | +| 🔄 **免重复扫码** | 浏览器会话持久化,一次登录长期使用 | +| 🌐 **多浏览器** | Chrome / Edge / Safari 任意切换 | ## 快速开始 +### macOS + ```bash -# 1. 安装依赖 +# 1. 克隆项目 +git clone https://github.com/sekiwhat/RedNoteDanmuBot.git +cd RedNoteDanmuBot + +# 2. 安装后端依赖 npm install -# 2. 安装前端依赖并构建 +# 3. 安装前端依赖并构建 cd client && npm install && npx vite build && cd .. -# 3. 启动服务 +# 4. 启动 npm run server ``` 打开浏览器访问 **http://localhost:3000** -## 使用说明 +### Windows -1. **启动服务**,浏览器打开控制面板 -2. 在 Playwright 打开的 Chrome 窗口中登录小红书 -3. 复制直播间 URL → 点击「连接」 -4. 在 **关键词** 页面设置你想发的词句(如"中!"、"冲冲冲") -5. 回到 **控制** 页面,输入前缀(如"76"),点击「开始发送」 -6. 切换到 **数据** 页面查看实时发送统计 +```bash +# 步骤同上,完全一致 +npm install +cd client && npm install && npx vite build && cd .. +npm run server +``` -### 消息构成 +### macOS 特别说明 -``` -用户前缀 + 关键词(随机选) + 随机符号/表情 - "76" "中!" "~!@😊" - "76" "冲冲冲" "🔥✨" +| 浏览器 | 是否需要额外操作 | +|--------|----------------| +| **Chrome** | ✅ 即开即用(需已安装 Chrome) | +| **Edge** | ✅ 即开即用 | +| **Safari / WebKit** | 运行 `npx playwright install webkit`(首次) | -→ 每次发送结果都不同,防止被吞 -``` +> 💡 macOS 上 Safari 模式使用的是 Playwright 自带的 WebKit 引擎(非系统 Safari),表现与 Chrome 一致。 -## 配置 +## 使用指南 -编辑 `server/config.js`: +### 第一步:连接直播间 -| 配置项 | 默认值 | 说明 | -|--------|--------|------| -| `port` | 3000 | 服务端口 | -| `defaultInterval` | 2500 | 默认发送间隔 (ms) | -| `minInterval` | 1500 | 最小发送间隔 (ms) | -| `maxInterval` | 8000 | 最大发送间隔 (ms) | -| `jitterRatio` | 0.3 | 间隔抖动比例 (±30%) | -| `headless` | false | 是否无头浏览器模式 | -| `userDataDir` | `.chromium-profile` | 浏览器用户数据目录 | +``` +① 启动服务 → ② 打开 http://localhost:3000 +③ 选择浏览器 → ④ 输入直播间 URL → ⑤ 点击「连接」 +⑥ 在 Playwright 打开的浏览器窗口中扫码登录小红书 +``` -## 开发 +### 第二步:选择消息模式 -```bash -# 前端热更新开发 -cd client && npx vite +**关键词模式**(默认):每次从你的词库中随机选一个,追加符号 -# 运行全部测试 -npx --node-options="--experimental-vm-modules" jest +``` +"76" + "中!" + "~!@😊" → "76 中!~!@😊" +"76" + "冲冲冲" + "🔥✨" → "76 冲冲冲🔥✨" +``` -# 构建前端 -cd client && npx vite build +**全随机模式**:前缀 + 随机字母/符号/表情(可调长度和字符集) + +``` +"76" + "xK@!😊" → "76 xK@!😊" +"76" + "Ab~#🌹" → "76 Ab~#🌹" ``` -## 项目结构 +### 第三步:开始发送 ``` -RedNoteDanmuBot/ -├── server/ -│ ├── index.js # Express + WebSocket 服务 -│ ├── bot.js # Playwright 弹幕发送核心 -│ ├── messageEngine.js # 智能消息引擎 -│ ├── database.js # SQLite 持久化 -│ ├── randomizer.js # 随机字符串生成 -│ ├── config.js # 默认配置 -│ └── __tests__/ # 13 个测试 -├── client/ -│ ├── src/ -│ │ ├── App.vue # 主控制面板 -│ │ ├── main.js -│ │ ├── style.css -│ │ └── components/ -│ │ ├── KeywordManager.vue # 关键词管理 -│ │ └── AnalyticsPanel.vue # 数据看板 -│ ├── index.html -│ └── vite.config.js -├── data/ # SQLite 数据库(自动创建) -├── .chromium-profile/ # 浏览器会话缓存 -├── package.json -└── README.md +设置前缀(如 "76")→ 调整间隔(建议 2000ms) +→ 点击「开始发送」→ 切到「数据」看实时统计 +→ 点击「停止」结束 ``` +## 配置 + +编辑 `server/config.js`: + +| 配置项 | 默认值 | 说明 | +|--------|--------|------| +| `port` | 3000 | 服务端口 | +| `browser` | `chrome` | 默认浏览器 | +| `defaultInterval` | 2500 | 发送间隔 (ms) | +| `minInterval` | 1500 | 最小间隔 | +| `maxInterval` | 8000 | 最大间隔 | +| `jitterRatio` | 0.3 | 抖动比例 (±30%) | +| `headless` | false | 无头模式 | +| `userDataDir` | `.chromium-profile` | 会话缓存目录 | + ## 技术栈 | 层 | 技术 | |----|------| | 后端 | Node.js + Express + WebSocket | -| 浏览器自动化 | Playwright | +| 浏览器 | Playwright (Chrome / Edge / WebKit) | | 前端 | Vue 3 + Chart.js + Vite | | 存储 | SQLite (better-sqlite3) | -## 测试 +## 项目结构 + +``` +RedNoteDanmuBot/ +├── server/ # 后端 +│ ├── index.js # Express + WebSocket +│ ├── bot.js # Playwright 核心 +│ ├── messageEngine.js # 智能消息引擎 +│ ├── database.js # SQLite 持久化 +│ ├── randomizer.js # 随机字符串 +│ ├── config.js # 配置 +│ └── __tests__/ # 13 个测试 +├── client/ # 前端 +│ └── src/ +│ ├── App.vue # 主面板 +│ ├── components/ +│ │ ├── KeywordManager.vue # 关键词管理 +│ │ └── AnalyticsPanel.vue # 数据看板 +│ └── style.css +├── data/ # SQLite(自动创建) +├── .chromium-profile/ # 浏览器会话(自动创建) +└── package.json +``` + +## 开发 ```bash +# 前端热更新 +cd client && npx vite + +# 运行测试 npx --node-options="--experimental-vm-modules" jest + +# 构建前端 +cd client && npx vite build ``` -13 个测试覆盖:randomizer / database / messageEngine +测试覆盖:`randomizer` / `database` / `messageEngine`,共 13 个。 -## 注意事项 +## 注意 -- 请合理使用,避免违反小红书社区规则 -- 发送频率建议 2-3 秒以上间隔 -- 首次使用需要在 Playwright 打开的浏览器中扫码登录 -- 登录态会保存在 `.chromium-profile/` 目录,下次免扫码 +- ⚠️ 请合理使用,遵守小红书社区规则 +- ⏱ 发送间隔建议 2000ms 以上,太快容易被风控 +- 🔑 首次使用需在 Playwright 打开的浏览器中扫码登录 +- 💾 登录态保存在 `.chromium-profile/`,下次免扫码 +- 🍎 macOS 用户首次使用 Safari 模式需先跑 `npx playwright install webkit` diff --git a/client/src/App.vue b/client/src/App.vue index a28ae03..b2faa2b 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -8,6 +8,12 @@ const url = ref(''); const browser = ref('chrome'); const prefix = ref('76'); const interval = ref(2500); +const mode = ref('keyword'); +const randomMinLen = ref(3); +const randomMaxLen = ref(6); +const useLetters = ref(true); +const useSymbols = ref(true); +const useEmojis = ref(true); const activeTab = ref('control'); const kwManagerRef = ref(null); const analyticsRef = ref(null); @@ -98,7 +104,21 @@ function disconnect() { } function start() { errorMsg.value = ''; - send({ type: 'start', prefix: prefix.value, options: { interval: interval.value } }); + send({ + type: 'start', + prefix: prefix.value, + options: { + interval: interval.value, + mode: mode.value, + random: { + minLen: randomMinLen.value, + maxLen: randomMaxLen.value, + useLetters: useLetters.value, + useSymbols: useSymbols.value, + useEmojis: useEmojis.value, + }, + }, + }); } function stop() { send({ type: 'stop' }); } @@ -194,12 +214,46 @@ function fmtTime(iso) {
前缀 - + 词 + {{ mode === 'keyword' ? '+ 词' : '+ 随机' }}
+ + +
+ 模式 +
+ + +
+
+
+
+ + +
+
🎲 随机设置
+
- 间隔 - - ms + 长度 + + + +
+
+ 字符 +
+ + + +
diff --git a/client/src/style.css b/client/src/style.css index 49e703f..14b7e27 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -315,6 +315,38 @@ body { gap: var(--space-sm); } +/* ─── Segmented Control ──────────────────────────────────────────── */ +.segmented { + display: flex; + border: 0.5px solid var(--separator); + border-radius: 7px; + overflow: hidden; + margin-left: auto; +} +.seg-btn { + padding: 5px 14px; + border: none; + background: transparent; + font-size: 13px; + font-weight: 500; + font-family: var(--font); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + -webkit-tap-highlight-color: transparent; + white-space: nowrap; +} +.seg-btn + .seg-btn { + border-left: 0.5px solid var(--separator); +} +.seg-btn.active { + background: var(--accent); + color: white; +} +.seg-btn:hover:not(.active) { + background: var(--surface-hover); +} + /* ─── Browser Select ─────────────────────────────────────────────── */ .browser-select { appearance: none; diff --git a/server/bot.js b/server/bot.js index b4fb9f4..a207a83 100644 --- a/server/bot.js +++ b/server/bot.js @@ -30,10 +30,8 @@ class DanmuBot { this.context = await chromium.launchPersistentContext(config.userDataDir, launchOptions); break; case 'safari': - this.context = await webkit.launchPersistentContext(config.userDataDir, { - ...launchOptions, - // Safari WebKit 使用 Playwright 自带的 WebKit 构建 - }); + // 使用 Playwright 自带的 WebKit 引擎(非系统 Safari) + this.context = await webkit.launchPersistentContext(config.userDataDir, launchOptions); break; case 'chromium': // Playwright 自带的 Chromium(不需要系统安装) @@ -67,11 +65,24 @@ class DanmuBot { this.sentCount = 0; const interval = options.interval || config.defaultInterval; - const template = options.template; + const mode = options.mode || 'keyword'; + const randomOptions = options.random || {}; while (!this._stopRequested) { try { - const { fullMessage } = buildMessage(prefix); + let fullMessage; + if (mode === 'random') { + const randomStr = generate({ + minLen: randomOptions.minLen || config.randomLengthMin, + maxLen: randomOptions.maxLen || config.randomLengthMax, + useLetters: randomOptions.useLetters !== false, + useSymbols: randomOptions.useSymbols !== false, + useEmojis: randomOptions.useEmojis !== false, + }); + fullMessage = prefix + ' ' + randomStr; + } else { + fullMessage = buildMessage(prefix).fullMessage; + } await this._sendMessage(fullMessage); this.sentCount++; From 5a49ecb616f9f55053afc889f24a3e0b9ffbbea9 Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Thu, 21 May 2026 15:50:14 +0800 Subject: [PATCH 13/15] fix: remove space between prefix and random/keyword --- README.md | 8 ++++---- client/src/App.vue | 2 +- server/__tests__/messageEngine.test.js | 4 ++-- server/bot.js | 2 +- server/messageEngine.js | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6e133a0..24e3df3 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,15 @@ npm run server **关键词模式**(默认):每次从你的词库中随机选一个,追加符号 ``` -"76" + "中!" + "~!@😊" → "76 中!~!@😊" -"76" + "冲冲冲" + "🔥✨" → "76 冲冲冲🔥✨" +"76" + "中!" + "~!@😊" → "76中!~!@😊" +"76" + "冲冲冲" + "🔥✨" → "76冲冲冲🔥✨" ``` **全随机模式**:前缀 + 随机字母/符号/表情(可调长度和字符集) ``` -"76" + "xK@!😊" → "76 xK@!😊" -"76" + "Ab~#🌹" → "76 Ab~#🌹" +"76" + "xK@!😊" → "76xK@!😊" +"76" + "Ab~#🌹" → "76Ab~#🌹" ``` ### 第三步:开始发送 diff --git a/client/src/App.vue b/client/src/App.vue index b2faa2b..6e8108a 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -214,7 +214,7 @@ function fmtTime(iso) {
前缀 - {{ mode === 'keyword' ? '+ 词' : '+ 随机' }} + {{ mode === 'keyword' ? '+词' : '+随机' }}
diff --git a/server/__tests__/messageEngine.test.js b/server/__tests__/messageEngine.test.js index 5891add..4d68455 100644 --- a/server/__tests__/messageEngine.test.js +++ b/server/__tests__/messageEngine.test.js @@ -14,14 +14,14 @@ test('buildMessage result contains the selected keyword', () => { test('buildMessage appends symbols after keyword (length ≥ 2)', () => { const result = buildMessage(PREFIX); - const symbolsPart = result.fullMessage.slice(PREFIX.length + 1 + result.keyword.length); + const symbolsPart = result.fullMessage.slice(PREFIX.length + result.keyword.length); expect(symbolsPart.length).toBeGreaterThanOrEqual(2); }); test('buildMessage random part contains no digits across 20 runs', () => { for (let i = 0; i < 20; i++) { const result = buildMessage(PREFIX); - const randomPart = result.fullMessage.slice(PREFIX.length + 1); + const randomPart = result.fullMessage.slice(PREFIX.length); expect(randomPart).not.toMatch(/[0-9]/); } }); diff --git a/server/bot.js b/server/bot.js index a207a83..135353f 100644 --- a/server/bot.js +++ b/server/bot.js @@ -79,7 +79,7 @@ class DanmuBot { useSymbols: randomOptions.useSymbols !== false, useEmojis: randomOptions.useEmojis !== false, }); - fullMessage = prefix + ' ' + randomStr; + fullMessage = prefix + randomStr; } else { fullMessage = buildMessage(prefix).fullMessage; } diff --git a/server/messageEngine.js b/server/messageEngine.js index c820f8c..59e2e27 100644 --- a/server/messageEngine.js +++ b/server/messageEngine.js @@ -8,7 +8,7 @@ export function buildMessage(prefix) { if (keywords.length === 0) { // Fallback: 没有启用关键词时用随机字符串 const randomStr = generate({ minLen: 3, maxLen: 6 }); - const fullMessage = prefix + ' ' + randomStr; + const fullMessage = prefix + randomStr; logSend(prefix, '(random)', fullMessage, 'success'); return { fullMessage, keyword: '(random)' }; } @@ -26,7 +26,7 @@ export function buildMessage(prefix) { }); // 4. 组装消息 - const fullMessage = prefix + ' ' + keyword + symbols; + const fullMessage = prefix + keyword + symbols; logSend(prefix, keyword, fullMessage, 'success'); return { fullMessage, keyword }; } From f40a84b1033358ae895260e233bf45de5636106c Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Thu, 21 May 2026 15:52:16 +0800 Subject: [PATCH 14/15] feat: change default mode to random --- README.md | 8 ++++---- client/src/App.vue | 2 +- server/bot.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 24e3df3..a2d3d56 100644 --- a/README.md +++ b/README.md @@ -67,14 +67,14 @@ npm run server ### 第二步:选择消息模式 -**关键词模式**(默认):每次从你的词库中随机选一个,追加符号 +**全随机模式**(默认):前缀 + 随机字母/符号/表情(可调长度和字符集) ``` -"76" + "中!" + "~!@😊" → "76中!~!@😊" -"76" + "冲冲冲" + "🔥✨" → "76冲冲冲🔥✨" +"76" + "xK@!😊" → "76xK@!😊" +"76" + "Ab~#🌹" → "76Ab~#🌹" ``` -**全随机模式**:前缀 + 随机字母/符号/表情(可调长度和字符集) +**关键词模式**:每次从你的词库中随机选一个,追加符号 ``` "76" + "xK@!😊" → "76xK@!😊" diff --git a/client/src/App.vue b/client/src/App.vue index 6e8108a..c1a6f30 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -8,7 +8,7 @@ const url = ref(''); const browser = ref('chrome'); const prefix = ref('76'); const interval = ref(2500); -const mode = ref('keyword'); +const mode = ref('random'); const randomMinLen = ref(3); const randomMaxLen = ref(6); const useLetters = ref(true); diff --git a/server/bot.js b/server/bot.js index 135353f..e0aa196 100644 --- a/server/bot.js +++ b/server/bot.js @@ -65,7 +65,7 @@ class DanmuBot { this.sentCount = 0; const interval = options.interval || config.defaultInterval; - const mode = options.mode || 'keyword'; + const mode = options.mode || 'random'; const randomOptions = options.random || {}; while (!this._stopRequested) { From 7f3a96987dc2b81a7ba204b8ff36c9bdbc98d4c6 Mon Sep 17 00:00:00 2001 From: Hansong Qi Date: Fri, 22 May 2026 18:20:39 +0800 Subject: [PATCH 15/15] feat: add direct send mode --- README.md | 13 ++++++++++--- client/src/App.vue | 11 +++++++++-- server/bot.js | 4 +++- server/config.js | 2 +- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a2d3d56..8babccd 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ | 功能 | 说明 | |------|------| | 🎮 **三面板控制** | 控制 / 关键词 / 数据,标签页切换 | -| 🧠 **双模式消息** | 关键词模式(前缀+词+符号)或全随机模式 | +| 🧠 **三模式消息** | 直接 / 关键词 / 全随机 三种消息模式 | | 📝 **关键词管理** | 增删改关键词,iOS 开关启用/禁用 | | 📊 **数据看板** | 实时折线图 + 7 日柱状图 + 4 张统计卡 | | 💾 **自动持久化** | SQLite 存储发送记录,重启不丢 | @@ -67,6 +67,13 @@ npm run server ### 第二步:选择消息模式 +**直接模式**:仅发送前缀,不加任何后缀 + +``` +"76" → "76" +"大家好" → "大家好" +``` + **全随机模式**(默认):前缀 + 随机字母/符号/表情(可调长度和字符集) ``` @@ -77,8 +84,8 @@ npm run server **关键词模式**:每次从你的词库中随机选一个,追加符号 ``` -"76" + "xK@!😊" → "76xK@!😊" -"76" + "Ab~#🌹" → "76Ab~#🌹" +"76" + "中!" + "~!@😊" → "76中!~!@😊" +"76" + "冲冲冲" + "🔥✨" → "76冲冲冲🔥✨" ``` ### 第三步:开始发送 diff --git a/client/src/App.vue b/client/src/App.vue index c1a6f30..2bc3649 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -7,7 +7,7 @@ import AnalyticsPanel from './components/AnalyticsPanel.vue'; const url = ref(''); const browser = ref('chrome'); const prefix = ref('76'); -const interval = ref(2500); +const interval = ref(3000); const mode = ref('random'); const randomMinLen = ref(3); const randomMaxLen = ref(6); @@ -214,13 +214,20 @@ function fmtTime(iso) {
前缀 - {{ mode === 'keyword' ? '+词' : '+随机' }} + {{ { keyword: '+词', random: '+随机', direct: '' }[mode] }} +
+ +
+ 间隔 + + ms
模式
+
diff --git a/server/bot.js b/server/bot.js index e0aa196..5ffe57b 100644 --- a/server/bot.js +++ b/server/bot.js @@ -71,7 +71,9 @@ class DanmuBot { while (!this._stopRequested) { try { let fullMessage; - if (mode === 'random') { + if (mode === 'direct') { + fullMessage = prefix; + } else if (mode === 'random') { const randomStr = generate({ minLen: randomOptions.minLen || config.randomLengthMin, maxLen: randomOptions.maxLen || config.randomLengthMax, diff --git a/server/config.js b/server/config.js index fa0cb9a..d3fc094 100644 --- a/server/config.js +++ b/server/config.js @@ -1,6 +1,6 @@ export default { port: 3000, - defaultInterval: 2500, + defaultInterval: 3000, minInterval: 1500, maxInterval: 8000, jitterRatio: 0.3,