feat: publish xianyu hunter hermes skill package

This commit is contained in:
2026-05-06 19:26:45 +08:00
commit 6384156097
59 changed files with 9627 additions and 0 deletions
+886
View File
@@ -0,0 +1,886 @@
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;
});
}