887 lines
28 KiB
JavaScript
887 lines
28 KiB
JavaScript
import { newPage } from './browser.mjs';
|
||
import { generateChatMessages, buildSessionPrompt, judgeGoalReached } from './ai.mjs';
|
||
import { randomDelay, sleep, waitIfVerification } from './utils.mjs';
|
||
|
||
export class ChatManager {
|
||
constructor(emitLog, shouldStop) {
|
||
this.sessions = new Map();
|
||
this.emitLog = emitLog;
|
||
this.shouldStop = shouldStop;
|
||
this._monitoring = false;
|
||
// WebSocket 监控状态
|
||
this._monitorPage = null; // 常驻 IM 页面
|
||
this._wsAlive = false; // WS 连接是否存活
|
||
this._wsReconnects = 0; // 重连次数
|
||
this._processingQueue = []; // 消息处理队列(防并发)
|
||
this._queueRunning = false;
|
||
this._catchingUp = false; // 补漏进行中(WS 消息只入队不处理)
|
||
}
|
||
|
||
async startChat(product, chatContext) {
|
||
if (!product.chatUrl) {
|
||
this.emitLog(` ⚠️ 商品 "${product.title.slice(0, 20)}..." 没有聊天链接,跳过`);
|
||
return null;
|
||
}
|
||
|
||
const sessionId = product.id;
|
||
if (this.sessions.has(sessionId)) {
|
||
this.emitLog(` ℹ️ 已有会话: ${sessionId}`);
|
||
return sessionId;
|
||
}
|
||
|
||
this.emitLog(`💬 发起聊天: ${product.title.slice(0, 30)}...`);
|
||
|
||
const systemPrompt = buildSessionPrompt(product, chatContext);
|
||
|
||
const session = {
|
||
id: sessionId,
|
||
product,
|
||
chatContext,
|
||
systemPrompt,
|
||
messages: [], // { role: 'self'|'other', content, time, fingerprint }
|
||
chatHistory: [], // { role: 'assistant'|'user', content } — for AI context
|
||
seenFingerprints: new Set(),
|
||
status: 'initiating',
|
||
goalReason: '',
|
||
lastChecked: Date.now(),
|
||
backoffLevel: 0,
|
||
page: null,
|
||
};
|
||
this.sessions.set(sessionId, session);
|
||
|
||
try {
|
||
const page = await newPage();
|
||
session.page = page;
|
||
|
||
const shortTitle = product.title.slice(0, 15);
|
||
await page.goto(product.chatUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
|
||
const verifyOk = await this._checkAndWaitVerification(page, shortTitle);
|
||
if (!verifyOk) {
|
||
session.status = 'error';
|
||
await page.close().catch(() => {});
|
||
session.page = null;
|
||
return null;
|
||
}
|
||
|
||
const loaded = await this._waitForChatReady(page, shortTitle);
|
||
if (!loaded) {
|
||
this.emitLog(` ⚠️ 聊天页面加载不完整,尝试继续发送`);
|
||
}
|
||
|
||
// 先提取页面上已有的聊天记录,作为 AI 对话上下文
|
||
const existingMessages = await extractMessages(page);
|
||
this._syncSnapshot(session, existingMessages);
|
||
|
||
if (existingMessages.length > 0) {
|
||
this.emitLog(` 📋 检测到页面已有 ${existingMessages.length} 条聊天记录,作为上下文`);
|
||
}
|
||
|
||
// 判断是否需要发送消息:如果最后一条消息是自己发的且对方未回复,不再重复发送
|
||
const lastMsg = session.messages[session.messages.length - 1];
|
||
const needSend = !lastMsg || lastMsg.role === 'other';
|
||
|
||
if (!needSend) {
|
||
this.emitLog(` ℹ️ 最后一条为自己发送的消息,等待对方回复,不重复发送`);
|
||
} else {
|
||
const msgs = await generateChatMessages(
|
||
product, session.chatHistory, { ...chatContext, emitLog: this.emitLog }
|
||
);
|
||
this.emitLog(` 生成 ${msgs.length} 条消息`);
|
||
|
||
for (const msg of msgs) {
|
||
await this._sendMessage(page, msg);
|
||
const fp = msgFingerprint('self', msg);
|
||
session.messages.push({ role: 'self', content: msg, time: Date.now(), fingerprint: fp });
|
||
session.chatHistory.push({ role: 'assistant', content: msg });
|
||
session.seenFingerprints.add(fp);
|
||
this.emitLog(` 📤 发送: "${msg}"`);
|
||
await randomDelay(2500, 4000);
|
||
}
|
||
|
||
// 发送后再次采集快照
|
||
await sleep(2000);
|
||
const postSendMessages = await extractMessages(page);
|
||
this._syncSnapshot(session, postSendMessages);
|
||
}
|
||
|
||
session.status = 'waiting';
|
||
session.lastChecked = Date.now();
|
||
await page.close().catch(() => {});
|
||
session.page = null;
|
||
|
||
this.emitLog(` ✅ 开场消息已发送,等待回复(快照 ${session.messages.length} 条消息)`);
|
||
return sessionId;
|
||
} catch (err) {
|
||
this.emitLog(` ❌ 聊天发起失败: ${err.message}`);
|
||
session.status = 'error';
|
||
if (session.page) await session.page.close().catch(() => {});
|
||
session.page = null;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 同步页面快照到 session:基于内容指纹做去重,
|
||
* 只把尚未见过的消息追加到 session.messages 和 chatHistory
|
||
*/
|
||
_syncSnapshot(session, pageMessages) {
|
||
let newCount = 0;
|
||
for (const pm of pageMessages) {
|
||
if (session.seenFingerprints.has(pm.fingerprint)) continue;
|
||
session.seenFingerprints.add(pm.fingerprint);
|
||
session.messages.push(pm);
|
||
session.chatHistory.push({
|
||
role: pm.role === 'self' ? 'assistant' : 'user',
|
||
content: pm.content,
|
||
});
|
||
newCount++;
|
||
}
|
||
return newCount;
|
||
}
|
||
|
||
async _sendMessage(page, text) {
|
||
// 闲鱼 IM 输入框: textarea with placeholder containing "请输入消息"
|
||
const inputSel = 'textarea[class*="textarea-no-border"], textarea[placeholder*="请输入消息"]';
|
||
const inputEl = page.locator(inputSel).first();
|
||
|
||
const visible = await inputEl.isVisible({ timeout: 3000 }).catch(() => false);
|
||
if (!visible) {
|
||
this.emitLog(' ⚠️ 未找到聊天输入框');
|
||
return;
|
||
}
|
||
|
||
await inputEl.click();
|
||
await sleep(300);
|
||
|
||
// 清空已有内容后输入
|
||
await inputEl.fill('');
|
||
await sleep(100);
|
||
for (const char of text) {
|
||
await inputEl.type(char, { delay: 30 + Math.random() * 60 });
|
||
}
|
||
await sleep(500);
|
||
|
||
// 闲鱼发送按钮: "发 送" (注意中间有空格)
|
||
const sendBtn = page.locator('button:has-text("发 送"), button:has-text("发送")').first();
|
||
const btnVisible = await sendBtn.isVisible({ timeout: 2000 }).catch(() => false);
|
||
if (btnVisible) {
|
||
await sendBtn.click();
|
||
await sleep(500);
|
||
return;
|
||
}
|
||
|
||
// 备选:按回车
|
||
await page.keyboard.press('Enter');
|
||
await sleep(500);
|
||
}
|
||
|
||
async _waitForChatReady(page, label = '') {
|
||
const prefix = label ? `[${label}] ` : '';
|
||
try {
|
||
await page.waitForSelector('#message-list-scrollable', { timeout: 15000 });
|
||
} catch {
|
||
this.emitLog(` ${prefix}⚠️ 消息列表15s内未加载,继续等待...`);
|
||
try {
|
||
await page.waitForSelector('#message-list-scrollable', { timeout: 20000 });
|
||
} catch {
|
||
this.emitLog(` ${prefix}⚠️ 消息列表加载超时,尝试继续`);
|
||
return false;
|
||
}
|
||
}
|
||
await sleep(1500);
|
||
|
||
await page.evaluate(() => {
|
||
const el = document.querySelector('#message-list-scrollable');
|
||
if (el) el.scrollTop = el.scrollHeight;
|
||
});
|
||
await sleep(800);
|
||
return true;
|
||
}
|
||
|
||
async _checkAndWaitVerification(page, label = '') {
|
||
return waitIfVerification(page, {
|
||
emitLog: this.emitLog,
|
||
shouldStop: this.shouldStop,
|
||
label,
|
||
});
|
||
}
|
||
|
||
// ==================== 监控入口 ====================
|
||
|
||
async monitorSessions() {
|
||
if (this._monitoring) return;
|
||
this._monitoring = true;
|
||
this.emitLog('👀 开始监控聊天会话...');
|
||
|
||
if (this.shouldStop()) {
|
||
this._monitoring = false;
|
||
this.emitLog(`👀 任务已被停止,聊天监控未启动(当前 ${this.sessions.size} 个会话已保存)`);
|
||
return;
|
||
}
|
||
|
||
const waitingCount = [...this.sessions.values()].filter(s => s.status === 'waiting').length;
|
||
if (waitingCount > 0) {
|
||
this.emitLog(` 📊 共 ${this.sessions.size} 个会话(${waitingCount} 个待监控)`);
|
||
}
|
||
|
||
// 第一步:立即建立 WS(补漏期间消息只入队不处理)
|
||
this._catchingUp = true;
|
||
try {
|
||
this.emitLog('🔌 建立 WebSocket 实时监听...');
|
||
await this._setupWsMonitor();
|
||
} catch (err) {
|
||
this.emitLog(` ⚠️ 初始 WS 连接失败: ${err.message}`);
|
||
}
|
||
|
||
// 第二步:对已有 waiting 会话做补漏
|
||
await this._catchUpSessions();
|
||
|
||
// 第三步:补漏完成,处理 WS 积压消息
|
||
this._catchingUp = false;
|
||
await this._drainDeferredQueue();
|
||
|
||
// 第四步:进入 WS 监控主循环(含重连逻辑)
|
||
await this._wsMonitorLoop();
|
||
|
||
this._monitoring = false;
|
||
await this._closeMonitorPage();
|
||
if (this.shouldStop()) {
|
||
this.emitLog(`👀 任务被手动停止,聊天监控结束(${this.sessions.size} 个会话已保存)`);
|
||
} else {
|
||
this.emitLog('👀 所有会话已结束,聊天监控停止');
|
||
}
|
||
}
|
||
|
||
// ==================== 启动补漏:DOM 快照检查 ====================
|
||
|
||
/**
|
||
* 对所有 waiting 会话做一次性 DOM 快照,捕获停机/断连期间遗漏的消息。
|
||
* 发现新消息的会话会触发 AI 回复。
|
||
*/
|
||
async _catchUpSessions() {
|
||
const waitingSessions = [...this.sessions.values()].filter(s => s.status === 'waiting');
|
||
if (waitingSessions.length === 0) return;
|
||
|
||
this.emitLog(` 🔄 补漏检查: ${waitingSessions.length} 个会话`);
|
||
|
||
for (const session of waitingSessions) {
|
||
if (this.shouldStop()) break;
|
||
try {
|
||
const hadNew = await this._checkSessionOnce(session);
|
||
if (hadNew) {
|
||
this.emitLog(` 📩 补漏发现新消息: ${session.product.title.slice(0, 20)}...`);
|
||
}
|
||
} catch (err) {
|
||
this.emitLog(` ⚠️ 补漏检查失败 ${session.id}: ${err.message}`);
|
||
}
|
||
await randomDelay(2000, 4000);
|
||
}
|
||
this.emitLog(` 🔄 补漏检查完成`);
|
||
}
|
||
|
||
/**
|
||
* 单次 DOM 快照检查(用于补漏和降级),打开聊天页提取消息后关闭。
|
||
* 如果有新的对方消息,触发 AI 回复。
|
||
*/
|
||
async _checkSessionOnce(session) {
|
||
const page = await newPage();
|
||
let hadNewReply = false;
|
||
const shortTitle = session.product.title.slice(0, 15);
|
||
try {
|
||
await page.goto(session.product.chatUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
|
||
const verifyOk = await this._checkAndWaitVerification(page, shortTitle);
|
||
if (!verifyOk) return false;
|
||
|
||
const loaded = await this._waitForChatReady(page, shortTitle);
|
||
if (!loaded) return false;
|
||
|
||
const pageMessages = await extractMessages(page);
|
||
session.lastChecked = Date.now();
|
||
|
||
const newOtherMsgs = pageMessages.filter(
|
||
m => m.role === 'other' && !session.seenFingerprints.has(m.fingerprint)
|
||
);
|
||
|
||
if (newOtherMsgs.length > 0) {
|
||
hadNewReply = true;
|
||
for (const msg of newOtherMsgs) {
|
||
session.seenFingerprints.add(msg.fingerprint);
|
||
session.messages.push(msg);
|
||
session.chatHistory.push({ role: 'user', content: msg.content });
|
||
this.emitLog(` 收到: "${msg.content}"`);
|
||
}
|
||
|
||
// 同步自己的新消息
|
||
const newSelfMsgs = pageMessages.filter(
|
||
m => m.role === 'self' && !session.seenFingerprints.has(m.fingerprint)
|
||
);
|
||
for (const msg of newSelfMsgs) {
|
||
session.seenFingerprints.add(msg.fingerprint);
|
||
session.messages.push(msg);
|
||
session.chatHistory.push({ role: 'assistant', content: msg.content });
|
||
}
|
||
|
||
// 判断目标 + AI 回复
|
||
await this._handleNewIncoming(session, page);
|
||
} else {
|
||
this._syncSnapshot(session, pageMessages);
|
||
}
|
||
} finally {
|
||
await page.close().catch(() => {});
|
||
}
|
||
return hadNewReply;
|
||
}
|
||
|
||
// ==================== WebSocket 实时监听 ====================
|
||
|
||
/**
|
||
* 主监控循环:打开 IM 页面,拦截 WS,断连时自动重连。
|
||
*/
|
||
async _wsMonitorLoop() {
|
||
const MAX_RECONNECTS = 10;
|
||
|
||
while (!this.shouldStop()) {
|
||
try {
|
||
// 如果 WS 未连接,建立连接
|
||
if (!this._wsAlive) {
|
||
this.emitLog('🔌 重新建立 WebSocket 连接...');
|
||
await this._setupWsMonitor();
|
||
}
|
||
|
||
// WS 已建立,进入等待循环
|
||
while (!this.shouldStop() && this._wsAlive) {
|
||
await sleep(3000);
|
||
// 所有会话都结束了,退出
|
||
if (!this._hasActiveSessions() && this.sessions.size > 0) break;
|
||
}
|
||
|
||
if (this.shouldStop()) break;
|
||
if (!this._hasActiveSessions() && this.sessions.size > 0) break;
|
||
|
||
// WS 断了,准备重连
|
||
this._wsReconnects++;
|
||
if (this._wsReconnects > MAX_RECONNECTS) {
|
||
this.emitLog(` ❌ WebSocket 重连次数超过 ${MAX_RECONNECTS},停止监控`);
|
||
break;
|
||
}
|
||
|
||
this.emitLog(` 🔄 WebSocket 断连,第 ${this._wsReconnects} 次重连...`);
|
||
await this._closeMonitorPage();
|
||
|
||
// 重连也遵循:先 WS 再补漏
|
||
this._catchingUp = true;
|
||
try {
|
||
this.emitLog('🔌 建立 WebSocket 实时监听...');
|
||
await this._setupWsMonitor();
|
||
} catch (err) {
|
||
this.emitLog(` ⚠️ 重连 WS 失败: ${err.message}`);
|
||
}
|
||
await this._catchUpSessions();
|
||
this._catchingUp = false;
|
||
await this._drainDeferredQueue();
|
||
|
||
await randomDelay(3000, 6000);
|
||
|
||
} catch (err) {
|
||
this.emitLog(` ⚠️ WebSocket 监控异常: ${err.message}`);
|
||
await this._closeMonitorPage();
|
||
await sleep(5000);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 打开 IM 页面并拦截 DingTalk IMPaaS WebSocket 连接。
|
||
*/
|
||
async _setupWsMonitor() {
|
||
this._wsAlive = false;
|
||
const page = await newPage();
|
||
this._monitorPage = page;
|
||
|
||
// 在页面导航前注册 WS 监听
|
||
page.on('websocket', ws => {
|
||
const url = ws.url();
|
||
// 只关注 DingTalk IMPaaS 连接
|
||
if (!url.includes('wss-goofish.dingtalk.com')) return;
|
||
|
||
this._wsAlive = true;
|
||
this._wsReconnects = 0;
|
||
this.emitLog(' ✅ WebSocket 连接已建立 (IMPaaS)');
|
||
|
||
ws.on('framereceived', frame => {
|
||
try {
|
||
this._onWsFrame(frame.payload);
|
||
} catch (err) {
|
||
this.emitLog(` ⚠️ WS 帧处理异常: ${err.message}`);
|
||
}
|
||
});
|
||
|
||
ws.on('close', () => {
|
||
this.emitLog(' ⚠️ WebSocket 连接已断开');
|
||
this._wsAlive = false;
|
||
});
|
||
});
|
||
|
||
// 监听页面崩溃/关闭
|
||
page.on('crash', () => {
|
||
this.emitLog(' ⚠️ 监控页面崩溃');
|
||
this._wsAlive = false;
|
||
});
|
||
page.on('close', () => {
|
||
this._wsAlive = false;
|
||
this._monitorPage = null;
|
||
});
|
||
|
||
// 打开 IM 首页(会自动建立 WS 连接)
|
||
await page.goto('https://www.goofish.com/im', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
|
||
const verifyOk = await this._checkAndWaitVerification(page, 'IM首页');
|
||
if (!verifyOk) {
|
||
throw new Error('IM 页面验证未通过');
|
||
}
|
||
|
||
// 等待 WS 连接建立
|
||
const wsTimeout = 15000;
|
||
const wsStart = Date.now();
|
||
while (!this._wsAlive && Date.now() - wsStart < wsTimeout) {
|
||
await sleep(500);
|
||
}
|
||
|
||
if (!this._wsAlive) {
|
||
throw new Error('WebSocket 连接超时');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理收到的 WS 帧,提取 /s/sync bizType=40 的聊天消息。
|
||
*/
|
||
_onWsFrame(payload) {
|
||
if (typeof payload !== 'string') return;
|
||
|
||
let frame;
|
||
try { frame = JSON.parse(payload); } catch { return; }
|
||
|
||
// 只关注服务端推送的 /s/sync 帧
|
||
const lwp = frame.lwp || '';
|
||
if (lwp !== '/s/sync') return;
|
||
|
||
const body = frame.body;
|
||
if (!body?.syncPushPackage?.data) return;
|
||
|
||
for (const item of body.syncPushPackage.data) {
|
||
// bizType 40 = 聊天消息
|
||
if (item.bizType !== 40) continue;
|
||
|
||
try {
|
||
const binary = atob(item.data);
|
||
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
||
const decoded = new TextDecoder('utf-8').decode(bytes);
|
||
const msgInfo = this._parseWsMessage(decoded);
|
||
if (msgInfo) {
|
||
this._enqueueMessage(msgInfo);
|
||
}
|
||
} catch (err) {
|
||
this.emitLog(` ⚠️ 消息解码失败: ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从 base64 解码后的二进制字符串中提取消息信息。
|
||
* 数据格式:二进制协议中嵌入了 JSON 和可读字段。
|
||
*/
|
||
_parseWsMessage(decoded) {
|
||
// 提取 senderUserId
|
||
const senderMatch = decoded.match(/senderUserId\x00?(.+?)(?:\x00|\x01)/);
|
||
const senderUserId = senderMatch ? senderMatch[1].replace(/[^\d]/g, '') : '';
|
||
|
||
// 提取消息内容 JSON
|
||
const contentMatch = decoded.match(/\{"atUsers".*?"text":\{"text":"((?:[^"\\]|\\.)*)"\}\}/);
|
||
const text = contentMatch ? contentMatch[1] : '';
|
||
|
||
// 提取 reminderTitle(发送者昵称)
|
||
const titleMatch = decoded.match(/reminderTitle\x00?(.+?)(?:\x00|\x01)/);
|
||
const senderName = titleMatch ? titleMatch[1].replace(/[\x00-\x1f]/g, '').trim() : '';
|
||
|
||
// 提取 itemId(从 reminderUrl)
|
||
const itemIdMatch = decoded.match(/itemId=(\d+)/);
|
||
const itemId = itemIdMatch ? itemIdMatch[1] : '';
|
||
|
||
// 提取 peerUserId(从 reminderUrl)
|
||
const peerMatch = decoded.match(/peerUserId=(\d+)/);
|
||
const peerUserId = peerMatch ? peerMatch[1] : '';
|
||
|
||
if (!text || !senderUserId) return null;
|
||
|
||
return { senderUserId, text, senderName, itemId, peerUserId };
|
||
}
|
||
|
||
/**
|
||
* 将 WS 消息加入处理队列(串行处理,防止并发回复冲突)。
|
||
*/
|
||
_enqueueMessage(msgInfo) {
|
||
this._processingQueue.push(msgInfo);
|
||
if (this._catchingUp) return; // 补漏期间只入队,不触发处理
|
||
if (!this._queueRunning) {
|
||
this._queueRunning = true;
|
||
this._processQueue().catch(err => {
|
||
this.emitLog(` ⚠️ 消息队列处理异常: ${err.message}`);
|
||
}).finally(() => {
|
||
this._queueRunning = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
async _processQueue() {
|
||
while (this._processingQueue.length > 0 && !this.shouldStop()) {
|
||
const msgInfo = this._processingQueue.shift();
|
||
await this._handleWsMessage(msgInfo);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 补漏完成后,统一处理积压的 WS 消息。
|
||
* 去重会自动过滤已被补漏覆盖的消息。
|
||
*/
|
||
async _drainDeferredQueue() {
|
||
if (this._processingQueue.length === 0) return;
|
||
this.emitLog(` 📬 处理补漏期间积压的 ${this._processingQueue.length} 条 WS 消息`);
|
||
this._queueRunning = true;
|
||
try {
|
||
await this._processQueue();
|
||
} finally {
|
||
this._queueRunning = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理一条 WS 推送的消息:匹配会话 → 记录 → 判断目标 → AI 回复。
|
||
*/
|
||
async _handleWsMessage(msgInfo) {
|
||
const { senderUserId, text, senderName, itemId } = msgInfo;
|
||
|
||
// 匹配会话:通过 chatUrl 中的 peerUserId 或 itemId
|
||
let session = null;
|
||
for (const s of this.sessions.values()) {
|
||
if (s.status !== 'waiting') continue;
|
||
const url = s.product.chatUrl || '';
|
||
const urlPeer = extractUrlParam(url, 'peerUserId');
|
||
const urlItem = extractUrlParam(url, 'itemId');
|
||
if ((urlPeer && urlPeer === senderUserId) || (urlItem && urlItem === itemId)) {
|
||
session = s;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!session) return; // 不是我们监控的会话
|
||
|
||
const shortTitle = session.product.title.slice(0, 20);
|
||
this.emitLog(` 📩 [WS] ${shortTitle}... 收到消息: "${text}"`);
|
||
|
||
// 去重
|
||
const fp = msgFingerprint('other', text);
|
||
if (session.seenFingerprints.has(fp)) {
|
||
this.emitLog(` ℹ️ 重复消息,跳过`);
|
||
return;
|
||
}
|
||
|
||
session.seenFingerprints.add(fp);
|
||
session.messages.push({ role: 'other', content: text, senderName, time: Date.now(), fingerprint: fp });
|
||
session.chatHistory.push({ role: 'user', content: text });
|
||
session.lastChecked = Date.now();
|
||
|
||
// 打开聊天页回复
|
||
const page = await newPage();
|
||
try {
|
||
await page.goto(session.product.chatUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
const verifyOk = await this._checkAndWaitVerification(page, shortTitle);
|
||
if (!verifyOk) {
|
||
this.emitLog(` ⚠️ ${shortTitle}... 回复页面验证失败`);
|
||
return;
|
||
}
|
||
await this._waitForChatReady(page, shortTitle);
|
||
await this._handleNewIncoming(session, page);
|
||
} catch (err) {
|
||
this.emitLog(` ⚠️ ${shortTitle}... 回复失败: ${err.message}`);
|
||
} finally {
|
||
await page.close().catch(() => {});
|
||
}
|
||
}
|
||
|
||
// ==================== 共用:目标判断 + AI 回复 ====================
|
||
|
||
/**
|
||
* 收到新消息后的通用处理:判断目标是否达成,未达成则生成 AI 回复。
|
||
* page 参数为已打开的聊天页面(用于发送消息)。
|
||
*/
|
||
async _handleNewIncoming(session, page) {
|
||
const shortTitle = session.product.title.slice(0, 20);
|
||
|
||
// 判断目标是否已达成
|
||
if (session.chatHistory.length >= 6) {
|
||
try {
|
||
const goalResult = await judgeGoalReached(
|
||
session.product, session.chatHistory, { ...session.chatContext, emitLog: this.emitLog }
|
||
);
|
||
if (goalResult.reached) {
|
||
session.status = 'goal_reached';
|
||
session.goalReason = goalResult.reason || '目标达成';
|
||
this.emitLog(` 🎯 ${shortTitle}... 聊天目标达成: ${session.goalReason}`);
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
this.emitLog(` ⚠️ 目标判断出错: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
// 生成 AI 回复
|
||
const replies = await generateChatMessages(
|
||
session.product,
|
||
session.chatHistory,
|
||
{ ...session.chatContext, emitLog: this.emitLog },
|
||
);
|
||
|
||
for (const reply of replies) {
|
||
await this._sendMessage(page, reply);
|
||
const fp = msgFingerprint('self', reply);
|
||
session.seenFingerprints.add(fp);
|
||
session.messages.push({ role: 'self', content: reply, time: Date.now(), fingerprint: fp });
|
||
session.chatHistory.push({ role: 'assistant', content: reply });
|
||
this.emitLog(` 📤 回复: "${reply}"`);
|
||
await randomDelay(2500, 4000);
|
||
}
|
||
}
|
||
|
||
// ==================== 辅助方法 ====================
|
||
|
||
_hasActiveSessions() {
|
||
return [...this.sessions.values()].some(s => s.status === 'waiting');
|
||
}
|
||
|
||
async _closeMonitorPage() {
|
||
if (this._monitorPage) {
|
||
await this._monitorPage.close().catch(() => {});
|
||
this._monitorPage = null;
|
||
}
|
||
this._wsAlive = false;
|
||
}
|
||
|
||
getSessionsData() {
|
||
const data = [];
|
||
for (const [id, session] of this.sessions) {
|
||
data.push({
|
||
id,
|
||
productTitle: session.product.title,
|
||
productPrice: session.product.price,
|
||
productDescription: session.product.description?.slice(0, 200) || '',
|
||
sellerName: session.product.sellerName || '',
|
||
status: session.status,
|
||
goalReason: session.goalReason || '',
|
||
messageCount: session.messages.length,
|
||
messages: session.messages.slice(-30).map(m => ({
|
||
role: m.role,
|
||
content: m.content,
|
||
time: m.time,
|
||
})),
|
||
lastChecked: session.lastChecked,
|
||
promptSummary: {
|
||
hasStrategy: !!session.chatContext?.chatStrategy,
|
||
hasProductContext: !!session.product.description,
|
||
hasRequirements: !!session.chatContext?.requirements,
|
||
},
|
||
});
|
||
}
|
||
return data;
|
||
}
|
||
|
||
getSessionsFullData() {
|
||
const data = [];
|
||
for (const [id, session] of this.sessions) {
|
||
data.push({
|
||
id,
|
||
product: session.product,
|
||
chatContext: session.chatContext,
|
||
status: session.status,
|
||
goalReason: session.goalReason || '',
|
||
messages: session.messages.map(m => ({
|
||
role: m.role,
|
||
content: m.content,
|
||
time: m.time,
|
||
fingerprint: m.fingerprint,
|
||
})),
|
||
chatHistory: session.chatHistory,
|
||
lastChecked: session.lastChecked,
|
||
backoffLevel: session.backoffLevel,
|
||
});
|
||
}
|
||
return data;
|
||
}
|
||
|
||
restoreSessions(savedSessions, chatContext) {
|
||
let restored = 0;
|
||
for (const s of savedSessions) {
|
||
if (s.status === 'goal_reached' || s.status === 'error') continue;
|
||
if (!s.product?.chatUrl) continue;
|
||
|
||
const ctx = s.chatContext || chatContext;
|
||
const systemPrompt = buildSessionPrompt(s.product, ctx);
|
||
|
||
const seenFingerprints = new Set();
|
||
const messages = (s.messages || []).map(m => {
|
||
const fp = m.fingerprint || msgFingerprint(m.role, m.content);
|
||
seenFingerprints.add(fp);
|
||
return { ...m, fingerprint: fp };
|
||
});
|
||
|
||
let chatHistory = s.chatHistory;
|
||
if (!chatHistory || chatHistory.length === 0) {
|
||
chatHistory = messages.map(m => ({
|
||
role: m.role === 'self' ? 'assistant' : 'user',
|
||
content: m.content,
|
||
}));
|
||
}
|
||
|
||
const session = {
|
||
id: s.id,
|
||
product: s.product,
|
||
chatContext: ctx,
|
||
systemPrompt,
|
||
messages,
|
||
chatHistory,
|
||
seenFingerprints,
|
||
status: 'waiting',
|
||
goalReason: s.goalReason || '',
|
||
lastChecked: Date.now(),
|
||
backoffLevel: 0,
|
||
page: null,
|
||
};
|
||
this.sessions.set(s.id, session);
|
||
restored++;
|
||
}
|
||
if (restored > 0) {
|
||
this.emitLog(` ♻️ 从持久化数据恢复了 ${restored} 个聊天会话`);
|
||
}
|
||
return restored;
|
||
}
|
||
|
||
async cleanup() {
|
||
await this._closeMonitorPage();
|
||
for (const session of this.sessions.values()) {
|
||
if (session.page) await session.page.close().catch(() => {});
|
||
}
|
||
this.sessions.clear();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成消息指纹,用于去重(同一角色+同样文本 = 同一条消息)
|
||
* 加入序号后缀处理同一人连续发相同内容的情况
|
||
*/
|
||
function msgFingerprint(role, content) {
|
||
return `${role}:${content.trim()}`;
|
||
}
|
||
|
||
/**
|
||
* 从 URL 中提取指定参数值
|
||
*/
|
||
function extractUrlParam(url, param) {
|
||
try {
|
||
const u = new URL(url);
|
||
return u.searchParams.get(param) || '';
|
||
} catch {
|
||
const match = url.match(new RegExp(`[?&]${param}=([^&]*)`));
|
||
return match ? match[1] : '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 在闲鱼 IM 页面上精确提取所有消息。
|
||
*
|
||
* DOM 结构关键点:
|
||
* - 消息列表在 #message-list-scrollable > .ant-list-items 下
|
||
* - 每条消息是 li.ant-list-item
|
||
* - 自己的消息: li 的 style 含 "direction: rtl",文本 class 含 "message-text-right"
|
||
* - 对方的消息: li 的 style 含 "direction: ltr",文本 class 含 "message-text-left"
|
||
* - 发送者名称在 message-row 内第一个小字体 div
|
||
* - 消息文本在 div[class*="message-text"] 的 span 中
|
||
*/
|
||
async function extractMessages(page) {
|
||
return page.evaluate(() => {
|
||
const results = [];
|
||
const roleCounters = {};
|
||
|
||
const msgRows = document.querySelectorAll('[class*="message-row"]');
|
||
|
||
for (const row of msgRows) {
|
||
const li = row.closest('li');
|
||
if (!li) continue;
|
||
|
||
const liStyle = li.getAttribute('style') || '';
|
||
let role;
|
||
if (liStyle.includes('direction: rtl') || liStyle.includes('direction:rtl')) {
|
||
role = 'self';
|
||
} else if (liStyle.includes('direction: ltr') || liStyle.includes('direction:ltr')) {
|
||
role = 'other';
|
||
} else {
|
||
// 备用判断:检查 message-text 的 class
|
||
const textEl = row.querySelector('[class*="message-text"]');
|
||
if (!textEl) continue;
|
||
const cls = textEl.className || '';
|
||
if (cls.includes('right')) {
|
||
role = 'self';
|
||
} else if (cls.includes('left')) {
|
||
role = 'other';
|
||
} else {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// 提取发送者名称
|
||
// 在 message-row 内,sender name 是 font-size: 12px 的那个 div
|
||
let senderName = '';
|
||
const allDivs = row.querySelectorAll('div');
|
||
for (const d of allDivs) {
|
||
const s = d.getAttribute('style') || '';
|
||
if (s.includes('font-size: 12px') && s.includes('color: rgb(102, 102, 102)')) {
|
||
senderName = d.textContent.trim();
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 提取消息文本
|
||
const textEl = row.querySelector('[class*="message-text"]');
|
||
if (!textEl) continue;
|
||
|
||
// 文本可能在 span 子元素中,或直接是 textContent
|
||
const spans = textEl.querySelectorAll('span');
|
||
let content = '';
|
||
if (spans.length > 0) {
|
||
content = [...spans].map(s => s.textContent.trim()).join('');
|
||
}
|
||
if (!content) {
|
||
content = textEl.textContent.trim();
|
||
}
|
||
|
||
if (!content || content.length > 500) continue;
|
||
|
||
// 处理同一 role 连续发同内容消息的去重序号
|
||
const baseKey = `${role}:${content}`;
|
||
roleCounters[baseKey] = (roleCounters[baseKey] || 0) + 1;
|
||
const count = roleCounters[baseKey];
|
||
const fingerprint = count > 1 ? `${baseKey}#${count}` : baseKey;
|
||
|
||
results.push({
|
||
role,
|
||
content,
|
||
senderName,
|
||
fingerprint,
|
||
time: Date.now(),
|
||
});
|
||
}
|
||
|
||
return results;
|
||
});
|
||
}
|