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; }); }