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
+291
View File
@@ -0,0 +1,291 @@
import { getApiKey, getBaseUrl, getCurrentModel, isConfigured } from './config.mjs';
export { getCurrentModel, setCurrentModel, getAvailableModels } from './config.mjs';
export async function chatCompletion(messages, { temperature = 0.7, maxTokens = 1024, timeoutMs = 45000 } = {}) {
if (!isConfigured()) {
throw new Error('AI 未配置:请先在控制台「设置」中配置 API 密钥和模型');
}
const apiKey = getApiKey();
const baseUrl = getBaseUrl();
const model = getCurrentModel();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
signal: controller.signal,
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
messages,
temperature,
max_tokens: maxTokens,
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`AI API error ${res.status}: ${err}`);
}
const data = await res.json();
const content = data.choices?.[0]?.message?.content ?? '';
if (!content) {
const diag = JSON.stringify({
choicesLen: data.choices?.length,
finishReason: data.choices?.[0]?.finish_reason,
error: data.error,
});
console.warn(`[AI] 模型返回空内容: ${diag}`);
}
return content;
} finally {
clearTimeout(timer);
}
}
export const DEFAULT_COARSE_PROMPT = `你是一个二手商品标题初筛助手。你只看标题,判断该商品是否与用户需求存在【明确冲突】。
判定规则:
- 只有标题内容和用户需求直接矛盾时才排除(例如:用户要笔记本,标题明确是手机壳/配件/维修/求购/租赁等完全不同的东西)
- 标题中没有提及的细节(成色、电池、价格、地区等)绝对不能作为排除理由
- 任何拿不准的情况一律保留,宁可多留不能误删`;
export const DEFAULT_FINE_PROMPT = `你是一个二手商品细筛助手。根据用户需求和商品详情页信息,判断该商品是否值得进一步沟通。
判定规则:
- 只有当商品信息与用户需求存在【明确冲突】时才不通过(例如:用户要求电池90%以上,详情明确写了电池80%)
- 商品描述中未提及的信息不算冲突,可以通过后续聊天了解
- 价格偏高不算冲突(可以砍价),只有远超合理范围才排除
- 拿不准的一律通过,宁可多聊不能错过`;
export async function judgeByTitle(titles, requirements, customPromptHead, emitLog) {
const head = (customPromptHead && customPromptHead.trim()) ? customPromptHead.trim() : DEFAULT_COARSE_PROMPT;
const prompt = `${head}
用户需求:${requirements}
商品列表(JSON数组):
${JSON.stringify(titles.map((t, i) => ({ index: i, title: t })))}
请返回纯JSON数组,包含所有【没有明确冲突】的商品index(即应该保留的)。
只有标题和需求直接矛盾才排除,其余全部保留。
示例:[0, 1, 2, 3, 5, 7]
只输出JSON数组,不要有任何其他文字。`;
if (emitLog) emitLog(` [AI请求·粗筛] 需求="${requirements.slice(0, 60)}..." ${titles.length}个标题`);
const reply = await chatCompletion([{ role: 'user', content: prompt }], { temperature: 0.1 });
if (emitLog) emitLog(` [AI返回·粗筛] ${reply.slice(0, 200)}`);
if (!reply || !reply.trim()) {
if (emitLog) emitLog(` ⚠️ AI返回为空,保留本批全部`);
return titles.map((_, i) => i);
}
try {
const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
return JSON.parse(cleaned);
} catch {
const matches = reply.match(/\d+/g);
return matches ? matches.map(Number) : titles.map((_, i) => i);
}
}
export async function judgeByDetail(product, requirements, customPromptHead, emitLog) {
const head = (customPromptHead && customPromptHead.trim()) ? customPromptHead.trim() : DEFAULT_FINE_PROMPT;
const prompt = `${head}
用户需求:${requirements}
商品信息:
- 标题:${product.title}
- 价格:${product.price}
- 描述:${product.description}
- 卖家:${product.sellerName}${product.sellerLocation}
- 卖家好评率:${product.sellerRating || '未知'}
- 想要人数:${product.wantCount || '未知'}
请返回JSON格式:{"pass": true/false, "reason": "简短理由"}
判定标准:只有商品信息与需求存在明确冲突时pass为false,拿不准则pass为true。
不要输出任何额外文字。`;
if (emitLog) emitLog(` [AI请求·细筛] "${product.title.slice(0, 30)}..." 需求="${requirements.slice(0, 50)}..."`);
const reply = await chatCompletion([{ role: 'user', content: prompt }], { temperature: 0.1 });
if (emitLog) emitLog(` [AI返回·细筛] ${reply.slice(0, 200)}`);
try {
const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
return JSON.parse(cleaned);
} catch {
return { pass: false, reason: '解析失败' };
}
}
// ========== 聊天 prompt 三要素构建 ==========
export const DEFAULT_PERSONA = `你是一位活跃在闲鱼平台的买家。性格开朗、礼貌、真诚,说话自带"元气感"和"氛围感"的女大学生买家。你的核心目标是通过提供极高的情绪价值,让卖家产生"卖给你很开心"的心理,从而以更优惠的价格(谈价)或更优的条件(包邮/送小礼物)达成交易。
## Communication Style:
1. **语气词丰富:** 多用"哒、呀、呢、喔、呜呜、滴、哈罗"。
2. **表情包达人:** 灵活使用 Emoji(✨, 🥺, 🎈, 🥳, 💡, 🤏)。
3. **高情绪价值:** 进场先夸宝贝,中场卖萌示弱,收尾爽快礼貌。
4. **拒绝冷冰冰:** 禁止直接发数字(如"100?"),必须包装成有温度的请求。
核心沟通规则:
1. 每条消息严格不超过30个字
2. 一次回复可以发2-4条短消息,分条发送。而且除了信息交流外必须要能体现出礼貌可爱的人设
3. 语气活泼自然,像真人聊天,绝对不能像AI
4. 大量使用口语化表达和语气词(呀、哦、呢、嘻嘻、哈哈、嗯嗯)
5. 善用~代替句号,偶尔用表情(但不要每条都有)
6. 禁止一次性发送超过30字的长段落,会被识别为机器人
7. 回复节奏要像真人:先回应对方说的,再追问新问题
沟通流程(按优先级推进):
- 先确认商品是否还在售
- 了解描述中未提及的信息和用户需求匹配度
- 进入谈价环节(根据下方谈价策略执行)
- 确认发货方式和时间
禁止行为:
- 不要自报家门说"我是XXX"
- 不要用书面语、不要太客气太正式
- 不要连续问多个问题,一次最多问两个个点`;
function buildChatStrategy(userStrategy, customPersona) {
const persona = (customPersona && customPersona.trim()) ? customPersona.trim() : DEFAULT_PERSONA;
const base = `【聊单策略 —— 你的人设与沟通方式】
${persona}`;
if (!userStrategy) return base;
return base + `
【本任务谈价策略 —— 议价时必须遵守】
${userStrategy}
执行要点:按上述策略灵活谈价,但不要生硬照搬策略原文。把策略内化为你自己的沟通节奏。`;
}
function buildProductContext(product) {
const lines = [`【卖家发布的商品信息 —— 你正在咨询的这件商品】`];
lines.push('');
if (product.title) lines.push(`商品标题:${product.title}`);
if (product.price || product.detailPrice) lines.push(`标价:${product.detailPrice || product.price}`);
if (product.description) lines.push(`商品描述:${product.description}`);
if (product.sellerName) lines.push(`卖家昵称:${product.sellerName}`);
if (product.sellerLocation) lines.push(`卖家所在地:${product.sellerLocation}`);
if (product.sellerRating) lines.push(`卖家好评率:${product.sellerRating}`);
if (product.wantCount) lines.push(`想要人数:${product.wantCount}`);
if (product.images?.length > 0) lines.push(`商品图片数量:${product.images.length}`);
return lines.join('\n');
}
function buildBuyerRequirements(requirements) {
return `【采购需求 —— 你的真实购买意图,不要直接告诉卖家】
${requirements}
注意:以上是你内心的筛选标准,用于判断是否值得继续沟通。
不要把这些需求原封不动地告诉卖家,而是通过自然的提问来获取信息。`;
}
export function buildSessionPrompt(product, { requirements, chatStrategy, persona } = {}) {
return [
buildChatStrategy(chatStrategy, persona),
'',
buildProductContext(product),
'',
buildBuyerRequirements(requirements || ''),
'',
'【输出格式要求】',
'返回纯JSON数组,每个元素是一条短消息字符串(≤15字)。',
'例如:["你好呀~", "这个还在吗", "成色怎么样呢"]',
'不要输出任何JSON以外的内容。',
].join('\n');
}
export async function judgeGoalReached(product, chatHistory, { requirements, chatStrategy, emitLog } = {}) {
const recentHistory = chatHistory.slice(-20);
const historyText = recentHistory.map(m => {
const label = m.role === 'assistant' ? '我' : '卖家';
return `${label}: ${m.content}`;
}).join('\n');
const goalContext = [];
if (chatStrategy) goalContext.push(`谈价策略:${chatStrategy}`);
if (requirements) goalContext.push(`采购需求:${requirements}`);
const prompt = `你是一个聊天目标判定助手。根据以下信息判断这段买卖对话是否已经达成聊天目标。
达成目标的标准(满足任一即可):
1. 卖家已同意在买家心理价位范围内成交(明确报出了可接受的价格或同意了买家的出价)
2. 买卖双方已确认交易细节(价格+发货方式),对话接近可以下单的状态
3. 卖家明确拒绝降价且态度坚决,继续谈判已无意义
4. 商品已售出/下架,卖家明确表示没货了
商品信息:
- 标题:${product.title}
- 标价:${product.detailPrice || product.price}
${goalContext.length > 0 ? goalContext.join('\n') : '(无特定策略)'}
对话记录:
${historyText}
请返回JSON格式:{"reached": true/false, "reason": "简短说明结论(20字以内)"}
不要输出任何额外文字。`;
if (emitLog) emitLog(` [AI请求·目标判定] "${product.title.slice(0, 25)}..." 近${recentHistory.length}条对话`);
const reply = await chatCompletion([{ role: 'user', content: prompt }], { temperature: 0.1, maxTokens: 256 });
if (emitLog) emitLog(` [AI返回·目标判定] ${reply.slice(0, 200)}`);
try {
const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
return JSON.parse(cleaned);
} catch {
return { reached: false, reason: '判断失败' };
}
}
export async function generateChatMessages(product, chatHistory, { requirements, chatStrategy, persona, emitLog } = {}) {
const systemPrompt = buildSessionPrompt(product, { requirements, chatStrategy, persona });
const messages = [
{ role: 'system', content: systemPrompt },
...chatHistory,
];
if (chatHistory.length === 0) {
messages.push({
role: 'user',
content: '现在你刚点进这个商品的聊天页面,请生成开场白。2-3条短消息,先打招呼再自然地问一个关于商品的问题。',
});
} else {
messages.push({
role: 'user',
content: '卖家刚刚回复了(见上方对话),请根据对方的回复自然地继续聊,1-3条短消息。',
});
}
if (emitLog) emitLog(` [AI请求·聊天] 历史${chatHistory.length}条 "${product.title.slice(0, 25)}..."`);
const reply = await chatCompletion(messages, { temperature: 0.8, maxTokens: 512 });
if (emitLog) emitLog(` [AI返回·聊天] ${reply.slice(0, 200)}`);
try {
const cleaned = reply.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
const parsed = JSON.parse(cleaned);
if (Array.isArray(parsed)) {
return parsed.filter(m => typeof m === 'string' && m.length > 0 && m.length <= 20);
}
return [String(parsed)];
} catch {
const lines = reply.split('\n')
.map(l => l.replace(/^[\s"'\d.、\-\[\]]+/, '').replace(/["\],]+$/, '').trim())
.filter(l => l.length > 0 && l.length <= 20);
return lines.length > 0 ? lines : ['你好呀~'];
}
}
+188
View File
@@ -0,0 +1,188 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const REPORT_DIR = path.join(ROOT, 'reports');
export function priceNumber(value) {
const m = String(value || '').replace(/,/g, '').match(/\d+(?:\.\d+)?/);
return m ? Number(m[0]) : null;
}
function normalize(value = '') {
return String(value || '').toLowerCase().replace(/\s+/g, '');
}
function queryTokens(task) {
const text = (task.config?.queries || []).join(' ').toLowerCase();
const tokens = text
.split(/[^a-z0-9\u4e00-\u9fa5]+/i)
.map(t => t.trim())
.filter(t => t.length >= 2);
return [...new Set(tokens)];
}
export function analyzeItem(item, task) {
const text = normalize(`${item.title || ''} ${item.description || ''}`);
const title = normalize(item.title || '');
const flags = [];
const positives = [];
let score = 60;
const badRules = [
['维修', '疑似维修/故障'], ['求购', '疑似求购'], ['租', '疑似租赁'], ['回收', '疑似回收'],
['展示', '疑似展示/仅展示'], ['配件', '疑似配件'], ['散热器', '疑似散热器/配件'],
['主板', '可能是主板/套装'], ['整机', '可能是整机非单件'], ['套装', '可能是套装'],
['坏', '疑似故障'], ['不亮', '疑似无法点亮'], ['暗病', '疑似暗病'], ['不能进系统', '无法进系统/疑似故障'], ['不能进系統', '无法进系统/疑似故障'],
];
for (const [word, label] of badRules) {
if (text.includes(word)) flags.push(label);
}
const wantsE31245v3 = /e3\s*-?\s*1245\s*[_-]?\s*v3/i.test((task.config?.queries || []).join(' '));
if (wantsE31245v3) {
const exactTarget = /e3\s*-?\s*1245\s*[_-]?\s*v3/i;
const otherE3v3 = /e3\s*-?\s*(12\d{2})\s*[_-]?\s*v3/ig;
const titleRaw = String(item.title || '');
const textRaw = `${item.title || ''} ${item.description || ''}`;
const models = [...textRaw.matchAll(otherE3v3)].map(m => m[1]);
const titleModels = [...titleRaw.matchAll(otherE3v3)].map(m => m[1]);
if (!exactTarget.test(textRaw)) {
score -= 35;
flags.push('未明确命中 E3-1245v3');
}
const wrongTitleModels = titleModels.filter(m => m !== '1245');
if (wrongTitleModels.length) {
score -= 35;
flags.push(`标题主型号疑似非1245v3E3-${[...new Set(wrongTitleModels)].join('/')}v3`);
} else if (models.some(m => m !== '1245') && !exactTarget.test(titleRaw)) {
score -= 18;
flags.push('详情/关联词含其他 E3 v3 型号,需核对主型号');
}
}
const tokens = queryTokens(task);
const matched = tokens.filter(t => title.includes(normalize(t)) || text.includes(normalize(t)));
const importantTokens = tokens.filter(t => /[a-z]*\d/.test(t) || /v\d/i.test(t));
const missingImportant = importantTokens.filter(t => !text.includes(normalize(t)));
if (matched.length > 0) {
score += Math.min(15, matched.length * 4);
positives.push(`命中关键词:${matched.slice(0, 4).join('/')}`);
}
if (missingImportant.length >= Math.max(1, Math.ceil(importantTokens.length / 2))) {
score -= 20;
flags.push(`关键型号不完整:缺 ${missingImportant.slice(0, 4).join('/')}`);
}
const price = priceNumber(item.detailPrice || item.price);
const min = task.config?.priceMin;
const max = task.config?.priceMax;
if (price != null) {
if (max != null && price > max) { score -= 25; flags.push(`超预算:${price} > ${max}`); }
else if (max != null && price <= max) { score += 8; positives.push(`价格在预算内:${price}`); }
if (min != null && price < min) { score -= 8; flags.push(`价格偏低需防异常:${price} < ${min}`); }
} else {
score -= 5;
flags.push('价格未识别');
}
if (item.description && String(item.description).trim().length >= 30) {
score += 8;
positives.push('描述较完整');
} else {
score -= 8;
flags.push('详情描述偏少');
}
if (item.images?.length) {
score += Math.min(6, item.images.length * 2);
positives.push(`图片${item.images.length}`);
}
if (item.sellerRating) positives.push(item.sellerRating);
if (item.wantCount && /\d/.test(item.wantCount)) flags.push(`热度:${item.wantCount}`);
if (item.localFineReason) positives.push(`本地细筛:${item.localFineReason}`);
score -= Math.min(40, [...new Set(flags)].filter(f => /疑似|可能|超预算|故障|/.test(f)).length * 10);
score = Math.max(0, Math.min(100, score));
let verdict = 'caution';
if (score >= 75 && !flags.some(f => /疑似|可能|超预算|故障|关键型号/.test(f))) verdict = 'recommend';
if (score < 45 || flags.some(f => /求购|维修|故障|超预算|关键型号/.test(f))) verdict = 'reject';
const reason = verdict === 'recommend'
? `匹配度较高,${positives.slice(0, 3).join('') || '暂无明显风险'}`
: verdict === 'caution'
? `需要人工核对:${[...new Set(flags)].slice(0, 3).join('') || '信息不足'}`
: `暂不推荐:${[...new Set(flags)].slice(0, 3).join('') || '综合分偏低'}`;
return {
score,
verdict,
reason,
flags: [...new Set(flags)].slice(0, 8),
positives: [...new Set(positives)].slice(0, 8),
analyzedAt: Date.now(),
};
}
export function buildAnalysis(task, { limit = 50 } = {}) {
const candidates = task.products?.fineFiltered || [];
const analyzed = candidates.map(item => ({ ...item, hermesAnalysis: analyzeItem(item, task) }));
const sorted = [...analyzed].sort((a, b) => (b.hermesAnalysis?.score || 0) - (a.hermesAnalysis?.score || 0));
const recommended = sorted.filter(i => i.hermesAnalysis.verdict === 'recommend');
const caution = sorted.filter(i => i.hermesAnalysis.verdict === 'caution');
const rejected = sorted.filter(i => i.hermesAnalysis.verdict === 'reject');
const summary = {
taskId: task.id,
taskName: task.name,
analyzedAt: Date.now(),
total: candidates.length,
recommendedCount: recommended.length,
cautionCount: caution.length,
rejectedCount: rejected.length,
hasGoodResults: recommended.length > 0,
topItems: sorted.slice(0, limit).map(item => ({
id: item.id,
title: item.title,
price: item.detailPrice || item.price || '',
location: item.sellerLocation || '',
href: item.href || item.url || '',
score: item.hermesAnalysis.score,
verdict: item.hermesAnalysis.verdict,
reason: item.hermesAnalysis.reason,
flags: item.hermesAnalysis.flags,
})),
};
return { items: analyzed, summary };
}
export function renderAnalysisMarkdown(task, summary) {
const verdictLabel = { recommend: '推荐', caution: '谨慎', reject: '排除' };
const lines = [];
lines.push(`# ${task.name || task.id} — Hermes 分析结果`);
lines.push('');
lines.push(`- 任务ID\`${task.id}\``);
lines.push(`- 分析时间:${new Date(summary.analyzedAt).toLocaleString('zh-CN', { hour12: false })}`);
lines.push(`- 候选数量:${summary.total}`);
lines.push(`- 推荐/谨慎/排除:${summary.recommendedCount}/${summary.cautionCount}/${summary.rejectedCount}`);
lines.push('');
const show = summary.topItems.filter(i => i.verdict !== 'reject').slice(0, 20);
if (!show.length) {
lines.push('暂无合适候选。');
} else {
for (const item of show) {
lines.push(`## ${item.score}分|${verdictLabel[item.verdict] || item.verdict}${item.title || '(无标题)'}`);
lines.push(`- 价格:${item.price || '-'}`);
lines.push(`- 地区:${item.location || '-'}`);
if (item.href) lines.push(`- 链接:${item.href}`);
lines.push(`- 判断:${item.reason}`);
if (item.flags?.length) lines.push(`- 风险:${item.flags.join('')}`);
lines.push('');
}
}
fs.mkdirSync(REPORT_DIR, { recursive: true });
const out = path.join(REPORT_DIR, `${task.id}-analysis.md`);
fs.writeFileSync(out, lines.join('\n'), 'utf-8');
return { markdown: lines.join('\n'), path: out };
}
+53
View File
@@ -0,0 +1,53 @@
import { chromium } from 'playwright';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const USER_DATA_DIR = path.join(__dirname, '..', 'browser-data');
let _context = null;
let _launching = null;
export async function getBrowserContext() {
if (_context) return _context;
if (_launching) return _launching;
_launching = chromium.launchPersistentContext(USER_DATA_DIR, {
headless: false,
viewport: { width: 1366, height: 900 },
locale: 'zh-CN',
args: [
'--disable-blink-features=AutomationControlled',
'--no-first-run',
],
});
_context = await _launching;
_launching = null;
_context.on('close', () => { _context = null; });
return _context;
}
export async function newPage() {
const ctx = await getBrowserContext();
return ctx.newPage();
}
export async function getOpenPagesInfo() {
const ctx = _context;
if (!ctx) return { browserOpen: false, pages: [] };
const pages = await Promise.all(ctx.pages().map(async (page, index) => ({
index,
url: page.url(),
title: await page.title().catch(() => ''),
})));
return { browserOpen: true, pages };
}
export async function closeBrowser() {
if (_context) {
await _context.close().catch(() => {});
_context = null;
}
}
+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;
});
}
+192
View File
@@ -0,0 +1,192 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, '..', 'data');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
export const PROVIDERS = [
{
id: 'openrouter',
name: 'OpenRouter',
baseUrl: 'https://openrouter.ai/api/v1',
models: [
{ id: 'minimax/minimax-m2.5', name: 'Minimax M2.5' },
{ id: 'google/gemini-2.5-flash-preview', name: 'Gemini 2.5 Flash' },
{ id: 'google/gemini-2.5-pro-preview', name: 'Gemini 2.5 Pro' },
{ id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3' },
{ id: 'deepseek/deepseek-r1', name: 'DeepSeek R1' },
{ id: 'qwen/qwen-2.5-72b-instruct', name: 'Qwen 2.5 72B' },
{ id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini' },
{ id: 'openai/gpt-4o', name: 'GPT-4o' },
{ id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet' },
],
},
{
id: 'openai',
name: 'OpenAI',
baseUrl: 'https://api.openai.com/v1',
models: [
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini' },
{ id: 'gpt-4.1', name: 'GPT-4.1' },
{ id: 'o3-mini', name: 'o3-mini' },
],
},
{
id: 'deepseek',
name: 'DeepSeek',
baseUrl: 'https://api.deepseek.com',
models: [
{ id: 'deepseek-chat', name: 'DeepSeek V3' },
{ id: 'deepseek-reasoner', name: 'DeepSeek R1' },
],
},
{
id: 'qwen',
name: '通义千问 (Qwen)',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
models: [
{ id: 'qwen-plus', name: 'Qwen Plus' },
{ id: 'qwen-turbo', name: 'Qwen Turbo' },
{ id: 'qwen-max', name: 'Qwen Max' },
{ id: 'qwen-long', name: 'Qwen Long' },
],
},
{
id: 'siliconflow',
name: 'SiliconFlow (硅基流动)',
baseUrl: 'https://api.siliconflow.cn/v1',
models: [
{ id: 'deepseek-ai/DeepSeek-V3', name: 'DeepSeek V3' },
{ id: 'deepseek-ai/DeepSeek-R1', name: 'DeepSeek R1' },
{ id: 'Qwen/Qwen2.5-72B-Instruct', name: 'Qwen 2.5 72B' },
{ id: 'THUDM/glm-4-9b-chat', name: 'GLM-4 9B' },
],
},
{
id: 'zhipu',
name: '智谱 AI (GLM)',
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
models: [
{ id: 'glm-4-flash', name: 'GLM-4 Flash' },
{ id: 'glm-4-plus', name: 'GLM-4 Plus' },
{ id: 'glm-4', name: 'GLM-4' },
],
},
{
id: 'moonshot',
name: 'Moonshot (Kimi)',
baseUrl: 'https://api.moonshot.cn/v1',
models: [
{ id: 'moonshot-v1-8k', name: 'Moonshot V1 8K' },
{ id: 'moonshot-v1-32k', name: 'Moonshot V1 32K' },
{ id: 'moonshot-v1-128k', name: 'Moonshot V1 128K' },
],
},
{
id: 'groq',
name: 'Groq',
baseUrl: 'https://api.groq.com/openai/v1',
models: [
{ id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B' },
{ id: 'gemma2-9b-it', name: 'Gemma 2 9B' },
{ id: 'mixtral-8x7b-32768', name: 'Mixtral 8x7B' },
],
},
{
id: 'custom',
name: '自定义 (OpenAI 兼容)',
baseUrl: '',
models: [],
},
];
const DEFAULT_CONFIG = {
provider: '',
apiKey: '',
baseUrl: '',
model: '',
customModels: [],
};
let _config = null;
export function loadConfig() {
if (_config) return _config;
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
_config = { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
} else {
_config = { ...DEFAULT_CONFIG };
}
} catch {
_config = { ...DEFAULT_CONFIG };
}
return _config;
}
export function saveConfig(newConfig) {
_config = { ...DEFAULT_CONFIG, ..._config, ...newConfig };
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(_config, null, 2), 'utf-8');
} catch (err) {
console.error('保存配置失败:', err.message);
}
return _config;
}
export function getConfig() {
return loadConfig();
}
export function isConfigured() {
const cfg = loadConfig();
return !!(cfg.apiKey && cfg.baseUrl && cfg.model);
}
export function getApiKey() {
return loadConfig().apiKey || '';
}
export function getBaseUrl() {
return loadConfig().baseUrl || '';
}
export function getCurrentModel() {
return loadConfig().model || '';
}
export function setCurrentModel(modelId) {
const cfg = loadConfig();
cfg.model = modelId;
saveConfig(cfg);
console.log(`🤖 AI 模型已切换为: ${modelId}`);
return true;
}
export function getAvailableModels() {
const cfg = loadConfig();
const provider = PROVIDERS.find(p => p.id === cfg.provider);
const presetModels = provider?.models || [];
const customModels = (cfg.customModels || []).map(m =>
typeof m === 'string' ? { id: m, name: m } : m
);
return [...presetModels, ...customModels];
}
export function getSafeConfig() {
const cfg = loadConfig();
return {
provider: cfg.provider,
apiKey: cfg.apiKey ? `${cfg.apiKey.slice(0, 8)}****${cfg.apiKey.slice(-4)}` : '',
baseUrl: cfg.baseUrl,
model: cfg.model,
customModels: cfg.customModels || [],
hasKey: !!cfg.apiKey,
};
}
+166
View File
@@ -0,0 +1,166 @@
import { newPage } from './browser.mjs';
import { randomDelay, extractUserId, sleep, waitIfVerification } from './utils.mjs';
const BATCH_SIZE = 15;
export async function coarseFilter(products, requirements, emitLog, shouldStop, customPromptHead) {
emitLog(`🔸 本地粗筛开始,共 ${products.length} 个商品(不调用外部模型接口)`);
const passed = [];
const rules = buildLocalRules(requirements, customPromptHead);
for (let i = 0; i < products.length; i += BATCH_SIZE) {
if (shouldStop()) break;
const batch = products.slice(i, i + BATCH_SIZE);
emitLog(` 本地判断第 ${i + 1}-${Math.min(i + BATCH_SIZE, products.length)} 个...`);
const kept = batch.filter(product => {
const result = localCoarseJudge(product, rules);
product.localCoarseReason = result.reason;
if (!result.pass) emitLog(`${product.title?.slice(0, 30) || ''} - ${result.reason}`);
return result.pass;
});
passed.push(...kept);
emitLog(` 本批保留 ${kept.length}/${batch.length}`);
await randomDelay(1000, 2000);
}
emitLog(`🔸 粗筛完成: ${passed.length}/${products.length} 通过`);
return passed;
}
export async function fineFilter(products, requirements, emitLog, shouldStop, customPromptHead) {
emitLog(`🔹 详情采集开始,共 ${products.length} 个商品(分析将由 Hermes 完成)`);
const passed = [];
const rules = buildLocalRules(requirements, customPromptHead);
for (let i = 0; i < products.length; i++) {
if (shouldStop()) break;
const product = products[i];
emitLog(` 细筛 [${i + 1}/${products.length}] ${product.title.slice(0, 30)}...`);
try {
const detail = await fetchProductDetail(product, emitLog, shouldStop);
const enriched = { ...product, ...detail };
const result = localFineJudge(enriched, rules);
enriched.localFineReason = result.reason;
enriched.hermesAnalysisStatus = 'pending';
if (result.pass) {
emitLog(` ✅ 已采集 - ${result.reason}`);
passed.push(enriched);
} else {
emitLog(` ❌ 本地排除 - ${result.reason}`);
}
} catch (err) {
emitLog(` ⚠️ 细筛出错,跳过: ${err.message}`);
}
await randomDelay(5000, 12000);
}
emitLog(`🔹 详情采集完成: ${passed.length}/${products.length} 个候选待 Hermes 分析`);
return passed;
}
function buildLocalRules(requirements = '', customPromptHead = '') {
const text = `${requirements}\n${customPromptHead}`.toLowerCase();
const negative = ['维修', '配件', '壳', '膜', '求购', '租', '回收', '展示机', '资源机', '监管机', '扩容', '改装'];
const requiredTokens = [];
for (const token of ['m1','m2','m3','m4','m5','16g','24g','32g','512','1t','国行']) {
if (text.includes(token)) requiredTokens.push(token);
}
return { negative, requiredTokens };
}
function normalizeText(value = '') {
return String(value || '').toLowerCase().replace(/\s+/g, '');
}
function localCoarseJudge(product, rules) {
const title = normalizeText(product.title);
const hit = rules.negative.find(word => title.includes(word));
if (hit) return { pass: false, reason: `标题疑似${hit}` };
const missing = rules.requiredTokens.filter(token => !title.includes(token));
if (missing.length >= 2) return { pass: false, reason: `标题缺少关键规格:${missing.slice(0, 3).join('/')}` };
return { pass: true, reason: '标题未见明确冲突' };
}
function localFineJudge(product, rules) {
const text = normalizeText(`${product.title || ''} ${product.description || ''}`);
const hit = rules.negative.find(word => text.includes(word));
if (hit) return { pass: false, reason: `详情疑似${hit}` };
return { pass: true, reason: '详情已采集,等待 Hermes 深度分析' };
}
async function fetchProductDetail(product, emitLog, shouldStop) {
const page = await newPage();
try {
await page.goto(product.href, { waitUntil: 'domcontentloaded', timeout: 30000 });
const verifyOk = await waitIfVerification(page, {
emitLog,
shouldStop,
label: product.title?.slice(0, 15),
});
if (!verifyOk) {
return { description: '', sellerName: '', sellerLocation: '', error: '校验未通过' };
}
await sleep(3000);
const detail = await page.evaluate(() => {
const descEl = document.querySelector('[class*="main--"][class*="open--"]')
|| document.querySelector('[class*="notLoginContainer"] [class*="main--"]');
const description = descEl?.textContent?.trim().slice(0, 500) || '';
const nickEl = document.querySelector('[class*="item-user-info-nick"]');
const sellerName = nickEl?.textContent?.trim() || '';
const labels = document.querySelectorAll('[class*="item-user-info-label"]');
const labelTexts = [...labels].map(l => l.textContent?.trim());
const sellerLocation = labelTexts[0] || '';
const sellerRating = labelTexts.find(t => t?.includes('好评')) || '';
const priceEl = document.querySelector('[class*="price--"][class*="windows"]')
|| document.querySelector('[class*="price--"]');
const detailPrice = priceEl?.textContent?.trim() || '';
const wantEl = document.querySelector('[class*="want--"]');
const wantCount = wantEl?.textContent?.trim() || '';
const chatLink = document.querySelector('a[href*="/im?itemId="]');
const chatUrl = chatLink?.href || '';
const buyLink = document.querySelector('a[href*="/create-order"]');
const buyUrl = buyLink?.href || '';
const images = [...document.querySelectorAll('[class*="carouselItem"] img')]
.map(img => img.src).filter(Boolean).slice(0, 5);
return {
description,
sellerName,
sellerLocation,
sellerRating,
detailPrice: detailPrice,
wantCount,
chatUrl,
buyUrl,
images,
};
});
const chatLink = await page.locator('a[href*="/im?itemId="]').first().getAttribute('href').catch(() => '');
if (chatLink) {
detail.chatUrl = chatLink.startsWith('http') ? chatLink : `https://www.goofish.com${chatLink}`;
detail.sellerId = extractUserId(chatLink);
}
return detail;
} catch (err) {
return { description: '', sellerName: '', sellerLocation: '', error: err.message };
} finally {
await page.close().catch(() => {});
}
}
+237
View File
@@ -0,0 +1,237 @@
import { getBrowserContext } from './browser.mjs';
import { sleep } from './utils.mjs';
const HOME_URL = 'https://www.goofish.com/';
const SEARCH_PROBE_URL = 'https://www.goofish.com/search?q=e3%201245%20v3';
const QR_LOGIN_URL = 'https://www.goofish.com/';
function accountTextValue(bodyText, labels) {
const lines = String(bodyText || '').split(/\n+/).map(s => s.trim()).filter(Boolean);
for (const label of labels) {
const idx = lines.findIndex(line => line === label || line.startsWith(`${label}`) || line.startsWith(`${label}:`));
if (idx >= 0) {
const inline = lines[idx].replace(new RegExp(`^${label}[:]?\\s*`), '').trim();
if (inline && inline !== label) return inline;
if (lines[idx + 1]) return lines[idx + 1];
}
}
return '';
}
async function extractAccountInfo(page, bodyText) {
const title = await page.title().catch(() => '');
const nickTexts = await page.locator('[class*="nick--"], [class*="user-name"], [class*="nickname"], [class*="userName"]').evaluateAll(nodes => (
nodes.map(n => n.textContent?.trim()).filter(Boolean).slice(0, 5)
)).catch(() => []);
const avatarUrl = await page.locator('[class*="user-order-container"] img, img[class*="avatar"], [class*="avatar"] img').first().getAttribute('src', { timeout: 1200 }).catch(() => '');
const accountName = nickTexts[0] || accountTextValue(bodyText, ['昵称', '用户名', '账号']) || '';
return {
accountName,
nickCandidates: nickTexts,
avatarUrl: avatarUrl || '',
pageTitle: title,
};
}
function normalizeCookieDomain(domain = '') {
const d = String(domain || '').trim();
if (!d) return '.goofish.com';
if (/^(goofish\.com|www\.goofish\.com|2\.taobao\.com|taobao\.com|login\.taobao\.com|alibaba\.com)$/i.test(d)) {
return `.${d.replace(/^\./, '')}`;
}
return d;
}
function normalizeCookie(cookie) {
if (!cookie || typeof cookie !== 'object') return null;
const name = String(cookie.name || '').trim();
const value = cookie.value == null ? '' : String(cookie.value);
if (!name) return null;
const normalized = {
name,
value,
domain: normalizeCookieDomain(cookie.domain),
path: cookie.path || '/',
httpOnly: !!cookie.httpOnly,
secure: cookie.secure !== false,
sameSite: ['Strict', 'Lax', 'None'].includes(cookie.sameSite) ? cookie.sameSite : 'Lax',
};
const rawExpires = cookie.expires ?? cookie.expirationDate ?? cookie.expiry;
if (rawExpires != null && rawExpires !== -1 && rawExpires !== 'Session') {
const expires = Number(rawExpires);
if (Number.isFinite(expires) && expires > 0) {
normalized.expires = expires > 1e12 ? Math.floor(expires / 1000) : Math.floor(expires);
}
}
return normalized;
}
export function parseCookiesPayload(payload) {
let data = payload;
if (typeof data === 'string') data = JSON.parse(data);
const cookies = Array.isArray(data)
? data
: Array.isArray(data?.cookies)
? data.cookies
: Array.isArray(data?.data)
? data.data
: null;
if (!cookies) throw new Error('Cookie JSON 格式不正确:请上传 Cookie 数组,或包含 cookies 数组的 JSON 对象');
const normalized = cookies.map(normalizeCookie).filter(Boolean);
if (!normalized.length) throw new Error('没有识别到有效 Cookie;每条 Cookie 至少需要 name/value/domain');
return normalized;
}
export async function importCookiesFromJson(payload, emitLog = () => {}) {
const cookies = parseCookiesPayload(payload);
const ctx = await getBrowserContext();
await ctx.addCookies(cookies);
emitLog(`✅ 已导入 ${cookies.length} 条 Cookie 到 Playwright 持久化浏览器`);
const storedCookies = await ctx.cookies().catch(() => []);
const goofishCookies = storedCookies.filter(c => /(^|\.)goofish\.com$|(^|\.)taobao\.com$|(^|\.)alibaba\.com$/.test(c.domain));
return {
imported: cookies.length,
status: {
loggedIn: goofishCookies.length > 5,
loginWall: false,
loginTextVisible: false,
nickVisible: false,
avatarVisible: false,
cardCount: 0,
cookieCount: goofishCookies.length,
accountName: '',
nickCandidates: [],
avatarUrl: '',
url: 'cookie-import://local',
title: 'Cookie 已导入,点击“检测登录状态”可联网验证',
checkedAt: new Date().toISOString(),
storage: 'browser-data/',
},
};
}
export async function openLoginPage(emitLog = () => {}) {
const ctx = await getBrowserContext();
const page = ctx.pages()[0] || await ctx.newPage();
emitLog('正在打开闲鱼登录/首页窗口...');
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(2000);
const status = await getLoginStatus(page);
if (status.loggedIn) emitLog('✅ 已检测到登录状态,cookie 已保存在 browser-data 中');
else emitLog('⚠️ 还未登录:请在弹出的 Chromium 窗口中完成扫码/验证码登录,完成后点击 Web 上的“检测登录状态”');
return status;
}
export async function getLoginQr(emitLog = () => {}) {
const ctx = await getBrowserContext();
const page = ctx.pages().find(p => /goofish\.com|login\.taobao\.com|havanaone/.test(p.url())) || ctx.pages()[0] || await ctx.newPage();
emitLog('正在打开闲鱼 Web 登录入口并生成后台二维码截图...');
await page.goto(QR_LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(2500);
let clicked = false;
for (const selector of ['text=立即登录', 'text=登录', 'button:has-text("登录")', 'a:has-text("登录")']) {
const loc = page.locator(selector).first();
if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) {
await loc.click({ timeout: 3000 }).catch(() => {});
clicked = true;
await sleep(2500);
break;
}
}
for (const selector of ['text=扫码登录', 'text=二维码登录', 'text=使用二维码登录', '.icon-qrcode', '[class*=qrcode]']) {
const loc = page.locator(selector).first();
if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) {
await loc.click({ timeout: 2500 }).catch(() => {});
await sleep(1500);
break;
}
}
const status = await getLoginStatus(page);
let qrImage = '';
if (!status.loggedIn) {
const modal = page.locator('[class*="login-modal"], [class*="Login"], [class*="qrcode"], [class*="qr"], iframe').first();
const target = await modal.isVisible({ timeout: 1500 }).catch(() => false) ? modal : page.locator('body');
const buf = await target.screenshot({ type: 'png', timeout: 8000 }).catch(() => null);
if (buf) qrImage = `data:image/png;base64,${buf.toString('base64')}`;
}
if (status.loggedIn) emitLog('✅ 当前账号已登录,无需扫码');
else emitLog(`已生成登录截图${clicked ? '' : '(未找到明确登录按钮,已截取当前页面)'};请在后台页面扫码后点“检测登录状态”`);
return { ...status, qrImage, qrCheckedAt: new Date().toISOString() };
}
export async function getLoginStatus(existingPage = null) {
const ctx = await getBrowserContext();
const page = existingPage || ctx.pages()[0] || await ctx.newPage();
if (!existingPage) {
await page.goto(SEARCH_PROBE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(async () => {
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
});
await sleep(3000);
}
const bodyText = await page.locator('body').innerText({ timeout: 3000 }).catch(() => '');
const hasLoginWall = /登录后可以更懂你|立即登录|请登录/.test(bodyText);
const loginTextVisible = await page.locator('text=登录').first().isVisible({ timeout: 1000 }).catch(() => false);
const nickVisible = await page.locator('[class*="nick--"]').first().isVisible({ timeout: 1500 }).catch(() => false);
const avatarVisible = await page.locator('[class*="user-order-container"] img').first().isVisible({ timeout: 1500 }).catch(() => false);
const cardCount = await page.locator('a[class*="feeds-item-wrap"], a[href*="/item"], a[href*="id="]').count().catch(() => 0);
const cookies = await ctx.cookies().catch(() => []);
const goofishCookies = cookies.filter(c => /(^|\.)goofish\.com$|(^|\.)taobao\.com$|(^|\.)alibaba\.com$/.test(c.domain));
const likelyLoggedIn = !hasLoginWall && !loginTextVisible && (nickVisible || avatarVisible || cardCount > 0 || goofishCookies.length > 5);
const accountInfo = likelyLoggedIn
? await extractAccountInfo(page, bodyText)
: { accountName: '', nickCandidates: [], avatarUrl: '', pageTitle: await page.title().catch(() => '') };
return {
loggedIn: likelyLoggedIn,
loginWall: hasLoginWall,
loginTextVisible,
nickVisible,
avatarVisible,
cardCount,
cookieCount: goofishCookies.length,
accountName: accountInfo.accountName,
nickCandidates: accountInfo.nickCandidates,
avatarUrl: accountInfo.avatarUrl,
url: page.url(),
title: accountInfo.pageTitle,
checkedAt: new Date().toISOString(),
storage: 'browser-data/',
};
}
export async function checkLogin(emitLog) {
const ctx = await getBrowserContext();
const page = ctx.pages()[0] || await ctx.newPage();
emitLog('正在打开闲鱼首页...');
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(3000);
for (let attempt = 0; attempt < 5; attempt++) {
const status = await getLoginStatus(page);
if (status.loggedIn) {
emitLog(`✅ 登录状态正常,cookie 数 ${status.cookieCount},持久化目录 browser-data/`);
return true;
}
const remaining = 5 - attempt - 1;
emitLog(`⚠️ 未检测到登录状态,请在浏览器中手动登录(剩余${remaining}次重试机会)`);
emitLog('等待20秒后重新检测...');
await sleep(20000);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {});
await sleep(3000);
}
emitLog('❌ 多次检测均未登录,请登录后重新启动任务');
return false;
}
+149
View File
@@ -0,0 +1,149 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { spawn } from 'child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const DATA_DIR = path.join(ROOT, 'data');
const NOTIFY_FILE = path.join(DATA_DIR, 'notify-config.json');
const DEFAULT_CONFIG = {
enabled: true,
notifyOnlyGood: true,
minScore: 75,
channels: [
{ id: 'feishu-default', type: 'hermes-send-message', name: '飞书当前会话', enabled: true, target: 'feishu:oc_3e963a150d98f254d8f38881bcfa1812' },
{ id: 'weixin-default', type: 'hermes-send-message', name: '微信默认会话', enabled: false, target: 'weixin' },
],
};
const HERMES_TARGET_OPTIONS = [
{ value: 'feishu', label: '飞书 Home' },
{ value: 'feishu:oc_3e963a150d98f254d8f38881bcfa1812', label: '飞书当前 DM' },
{ value: 'weixin', label: '微信 Home' },
{ value: 'weixin:o9cq809DWSYDlvbA9pj1akncjrhY@im.wechat', label: '微信 DM 1' },
{ value: 'weixin:o9cq808qCd1W-cIDo0iWjteE5lHY@im.wechat', label: '微信 DM 2' },
{ value: 'qqbot', label: 'QQ 机器人 Home(如已配置)' },
];
export function getNotifyConfig() {
try {
if (!fs.existsSync(NOTIFY_FILE)) return { ...DEFAULT_CONFIG, targetOptions: HERMES_TARGET_OPTIONS };
const cfg = JSON.parse(fs.readFileSync(NOTIFY_FILE, 'utf-8'));
return { ...DEFAULT_CONFIG, ...cfg, targetOptions: HERMES_TARGET_OPTIONS, channels: Array.isArray(cfg.channels) ? cfg.channels : DEFAULT_CONFIG.channels };
} catch {
return { ...DEFAULT_CONFIG, targetOptions: HERMES_TARGET_OPTIONS };
}
}
export function saveNotifyConfig(config = {}) {
const normalized = {
enabled: config.enabled !== false,
notifyOnlyGood: config.notifyOnlyGood !== false,
minScore: Math.max(0, Math.min(100, Number(config.minScore ?? 75))),
channels: (config.channels || []).map((c, i) => ({
id: c.id || `${c.type || 'channel'}-${Date.now()}-${i}`,
type: c.type || 'hermes-send-message',
name: c.name || c.type || '通知通道',
enabled: !!c.enabled,
url: c.url || '',
token: c.token || '',
target: c.target || c.platform || 'feishu',
})),
};
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(NOTIFY_FILE, JSON.stringify(normalized, null, 2), 'utf-8');
return { ...normalized, targetOptions: HERMES_TARGET_OPTIONS };
}
export function getHermesTargetOptions() {
return HERMES_TARGET_OPTIONS;
}
function buildNotificationText(task, summary) {
const label = { recommend: '推荐', caution: '谨慎', reject: '排除' };
const items = summary.topItems
.filter(i => i.verdict === 'recommend' || i.score >= summary.minScore)
.slice(0, 5);
const lines = [];
lines.push(`🐟 闲鱼任务「${task.name || task.id}」发现 ${summary.recommendedCount} 个推荐候选`);
lines.push(`候选/推荐/谨慎/排除:${summary.total}/${summary.recommendedCount}/${summary.cautionCount}/${summary.rejectedCount}`);
lines.push('');
for (const item of items) {
lines.push(`${item.score}分|${label[item.verdict] || item.verdict}${item.title || ''}`);
lines.push(`价格:${item.price || '-'}|地区:${item.location || '-'}`);
if (item.reason) lines.push(`判断:${item.reason}`);
if (item.href) lines.push(item.href);
lines.push('');
}
if (summary.reportPath) lines.push(`报告:${summary.reportPath}`);
return lines.join('\n').trim();
}
async function runHermesSendMessage(target, message) {
return new Promise((resolve, reject) => {
const script = `import json\nfrom tools.send_message_tool import send_message_tool\nprint(send_message_tool({"action":"send","target":${JSON.stringify(target)},"message":${JSON.stringify(message)}}))\n`;
const child = spawn(process.env.HERMES_PYTHON || '/Users/chick/.hermes/hermes-agent/venv/bin/python3', ['-c', script], { cwd: process.env.HERMES_AGENT_DIR || '/Users/chick/.hermes/hermes-agent' });
let stdout = '';
let stderr = '';
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Hermes send_message 超时')); }, 60000);
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.on('error', err => { clearTimeout(timer); reject(err); });
child.on('close', code => {
clearTimeout(timer);
if (code !== 0) return reject(new Error((stderr || stdout || `Hermes send_message 退出码 ${code}`).slice(0, 500)));
try {
const parsed = JSON.parse(stdout.trim().split('\n').pop() || '{}');
if (parsed.error) return reject(new Error(parsed.error));
resolve(parsed);
} catch {
resolve({ ok: true, stdout: stdout.trim(), stderr: stderr.trim() });
}
});
});
}
async function postJson(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
return text;
}
export async function sendNotifications(task, summary, options = {}) {
const cfg = options.config || getNotifyConfig();
const minScore = Number(cfg.minScore ?? 75);
const shouldNotify = cfg.enabled !== false && (!cfg.notifyOnlyGood || summary.topItems.some(i => i.verdict === 'recommend' || i.score >= minScore));
if (!shouldNotify && !options.force) {
return { ok: true, skipped: true, reason: '没有达到通知条件' };
}
const message = buildNotificationText(task, { ...summary, minScore });
const results = [];
for (const ch of (cfg.channels || []).filter(c => c.enabled)) {
try {
if (ch.type === 'hermes-send-message' || ch.type === 'hermes-webhook') {
if (!ch.target) throw new Error('请选择 Hermes 通知目标');
await runHermesSendMessage(ch.target, message);
} else if (ch.type === 'bark') {
const base = (ch.url || '').replace(/\/$/, '');
const endpoint = ch.token ? `${base}/${encodeURIComponent(ch.token)}` : base;
await postJson(endpoint, { title: `闲鱼发现候选:${task.name || task.id}`, body: message, group: 'xianyu-hunter' });
} else if (ch.type === 'pushplus') {
await postJson(ch.url || 'https://www.pushplus.plus/send', { token: ch.token, title: `闲鱼发现候选:${task.name || task.id}`, content: message, template: 'txt' });
} else {
await postJson(ch.url, { title: `闲鱼发现候选:${task.name || task.id}`, message, taskId: task.id });
}
results.push({ id: ch.id, name: ch.name, ok: true });
} catch (err) {
results.push({ id: ch.id, name: ch.name, ok: false, error: err.message });
}
}
return { ok: results.every(r => r.ok), skipped: false, message, results };
}
+387
View File
@@ -0,0 +1,387 @@
import { newPage } from './browser.mjs';
import { randomDelay, sleep, waitIfVerification } from './utils.mjs';
const BASE_SEARCH_URL = 'https://www.goofish.com/search';
export async function searchProducts(query, filters, maxPages, emitLog, shouldStop) {
const page = await newPage();
const allProducts = [];
try {
const url = new URL(BASE_SEARCH_URL);
url.searchParams.set('q', query);
emitLog(`🔍 搜索: "${query}" (最多${maxPages}页)`);
await page.goto(url.toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(4000);
const verifyOk = await waitIfVerification(page, { emitLog, shouldStop, label: query });
if (!verifyOk) {
emitLog(`⚠️ 搜索 "${query}" 校验未通过,跳过`);
return allProducts;
}
await applyFilters(page, filters, emitLog);
await sleep(2000);
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
if (shouldStop()) break;
const pageVerify = await waitIfVerification(page, { emitLog, shouldStop, label: `${query} P${pageNum}` });
if (!pageVerify) break;
emitLog(`📄 正在采集第 ${pageNum} 页...`);
const products = await scrapeCurrentPage(page);
emitLog(` 找到 ${products.length} 个商品`);
allProducts.push(...products);
if (pageNum < maxPages) {
const hasNext = await goNextPage(page);
if (!hasNext) {
emitLog(' 已到最后一页');
break;
}
await randomDelay(3000, 6000);
}
}
} catch (err) {
emitLog(`❌ 搜索出错: ${err.message}`);
} finally {
await page.close().catch(() => {});
}
emitLog(`🔍 "${query}" 搜索完成,共采集 ${allProducts.length} 个商品`);
return allProducts;
}
async function applyFilters(page, filters, emitLog) {
try {
// ---- 个人闲置勾选 ----
if (filters.personalSeller) {
const checkbox = page.locator('[class*="search-checkbox-item-container"]:has-text("个人闲置")');
const visible = await checkbox.isVisible({ timeout: 3000 }).catch(() => false);
if (visible) {
await checkbox.click();
emitLog(' 已勾选: 个人闲置');
await sleep(1500);
} else {
emitLog(' ⚠️ 未找到"个人闲置"复选框');
}
}
// ---- 价格区间 ----
if (filters.priceMin != null || filters.priceMax != null) {
const priceInputs = page.locator('input[class*="search-price-input"]');
const count = await priceInputs.count();
if (count >= 2) {
if (filters.priceMin != null) {
await priceInputs.nth(0).click();
await priceInputs.nth(0).fill('');
await priceInputs.nth(0).pressSequentially(String(filters.priceMin), { delay: 80 });
await sleep(300);
}
if (filters.priceMax != null) {
await priceInputs.nth(1).click();
await priceInputs.nth(1).fill('');
await priceInputs.nth(1).pressSequentially(String(filters.priceMax), { delay: 80 });
await sleep(300);
}
await sleep(500);
const confirmBtn = page.locator('button[class*="search-price-confirm"]');
try {
await confirmBtn.click({ timeout: 5000 });
emitLog(` 已设置价格区间: ${filters.priceMin ?? '不限'} - ${filters.priceMax ?? '不限'}`);
await sleep(2000);
} catch {
try {
const lastInput = priceInputs.nth(filters.priceMax != null ? 1 : 0);
await lastInput.press('Enter');
emitLog(` 已设置价格区间(回车提交): ${filters.priceMin ?? '不限'} - ${filters.priceMax ?? '不限'}`);
await sleep(2000);
} catch {
emitLog(' ⚠️ 价格筛选提交失败,按钮和回车均无效');
}
}
} else {
emitLog(` ⚠️ 价格输入框未找到(found ${count}),价格筛选未生效`);
}
}
// ---- 地域筛选(通过页面区域面板) ----
if (filters.region) {
await applyRegionFilter(page, filters.region, emitLog);
}
} catch (err) {
emitLog(` 筛选器应用失败: ${err.message}`);
}
}
/**
* 通过闲鱼搜索页的"区域"面板设置地域筛选。
* 支持输入:省份名(四川)、城市名(成都)、或"省份 城市"组合。
* 面板结构:点击"区域"按钮 → 左列省份 → 右列城市 → 确认
*/
async function applyRegionFilter(page, regionInput, emitLog) {
const input = regionInput.trim().replace(/[,,、\s]+/g, ' ');
const parts = input.split(' ').filter(Boolean);
// 所有省份名
const PROVINCES = [
'北京','天津','河北','山西','内蒙古','辽宁','吉林','黑龙江',
'上海','江苏','浙江','安徽','福建','江西','山东','河南',
'湖北','湖南','广东','广西','海南','重庆','四川','贵州',
'云南','西藏','陕西','甘肃','青海','宁夏','新疆','台湾',
'香港','澳门','海外',
];
// 区域组
const REGION_GROUPS = ['珠三角','江浙沪','京津冀','东三省'];
// 主要城市→省份映射(覆盖常见大城市)
const CITY_PROVINCE_MAP = {
'成都':'四川','绵阳':'四川','德阳':'四川','宜宾':'四川','南充':'四川','泸州':'四川','达州':'四川','乐山':'四川','眉山':'四川',
'广州':'广东','深圳':'广东','东莞':'广东','佛山':'广东','珠海':'广东','惠州':'广东','中山':'广东',
'杭州':'浙江','宁波':'浙江','温州':'浙江','嘉兴':'浙江','绍兴':'浙江','金华':'浙江',
'南京':'江苏','苏州':'江苏','无锡':'江苏','常州':'江苏','南通':'江苏','徐州':'江苏',
'武汉':'湖北','宜昌':'湖北','襄阳':'湖北',
'长沙':'湖南','株洲':'湖南','湘潭':'湖南','衡阳':'湖南',
'济南':'山东','青岛':'山东','烟台':'山东','潍坊':'山东','临沂':'山东',
'郑州':'河南','洛阳':'河南','开封':'河南','南阳':'河南',
'石家庄':'河北','唐山':'河北','保定':'河北','邯郸':'河北',
'太原':'山西','大同':'山西',
'西安':'陕西','咸阳':'陕西','宝鸡':'陕西',
'合肥':'安徽','芜湖':'安徽','蚌埠':'安徽',
'福州':'福建','厦门':'福建','泉州':'福建',
'南昌':'江西','赣州':'江西','九江':'江西',
'昆明':'云南','大理':'云南','丽江':'云南',
'贵阳':'贵州','遵义':'贵州',
'兰州':'甘肃','天水':'甘肃',
'沈阳':'辽宁','大连':'辽宁','鞍山':'辽宁',
'长春':'吉林','吉林市':'吉林',
'哈尔滨':'黑龙江','大庆':'黑龙江','齐齐哈尔':'黑龙江',
'南宁':'广西','桂林':'广西','柳州':'广西',
'海口':'海南','三亚':'海南',
'呼和浩特':'内蒙古','包头':'内蒙古','鄂尔多斯':'内蒙古',
'银川':'宁夏',
'西宁':'青海',
'乌鲁木齐':'新疆',
'拉萨':'西藏',
};
// 解析输入:分离出省份和城市
let province = null;
let city = null;
for (const part of parts) {
if (PROVINCES.includes(part)) {
province = part;
} else if (REGION_GROUPS.includes(part)) {
province = part;
} else if (CITY_PROVINCE_MAP[part]) {
city = part;
if (!province) province = CITY_PROVINCE_MAP[part];
} else {
// 模糊匹配:看看是否是省份的一部分
const matched = PROVINCES.find(p => p.includes(part) || part.includes(p));
if (matched) {
province = matched;
} else {
// 尝试作为城市处理,在面板中动态匹配
city = part;
}
}
}
if (!province && !city) {
emitLog(` ⚠️ 无法识别地区: "${regionInput}"`);
return;
}
emitLog(` 地区解析: 省份="${province || '自动'}" 城市="${city || '不限'}"`);
// Step 1: 点击"区域"按钮打开面板
const areaBtn = page.locator('[class*="areaText"]').first();
const areaVisible = await areaBtn.isVisible({ timeout: 3000 }).catch(() => false);
if (!areaVisible) {
emitLog(' ⚠️ 未找到区域筛选按钮');
return;
}
await areaBtn.click();
await sleep(1200);
// Step 2: 在左列找到并点击省份
const panel = page.locator('[class*="panel--"]').first();
const panelVisible = await panel.isVisible({ timeout: 2000 }).catch(() => false);
if (!panelVisible) {
emitLog(' ⚠️ 区域面板未弹出');
return;
}
if (province) {
const provItems = panel.locator('[class*="provItem"]');
const provCount = await provItems.count();
let clicked = false;
for (let i = 0; i < provCount; i++) {
const text = await provItems.nth(i).textContent().catch(() => '');
if (text.trim() === province) {
await provItems.nth(i).click();
clicked = true;
emitLog(` 已选择省份: ${province}`);
await sleep(1000);
break;
}
}
if (!clicked) {
emitLog(` ⚠️ 省份列表中未找到: "${province}"`);
}
}
// Step 3: 如果指定了城市,在右列找到并点击
if (city) {
await sleep(800);
// 右列是面板中第二个 col
const cols = panel.locator('[class*="col--"]');
const colCount = await cols.count();
if (colCount >= 2) {
const rightCol = cols.nth(1);
const cityItems = rightCol.locator('[class*="provItem"]');
const cityCount = await cityItems.count();
let cityClicked = false;
for (let i = 0; i < cityCount; i++) {
const text = await cityItems.nth(i).textContent().catch(() => '');
if (text.trim() === city) {
await cityItems.nth(i).click();
cityClicked = true;
emitLog(` 已选择城市: ${city}`);
await sleep(800);
break;
}
}
if (!cityClicked && city) {
// 如果没有找到精确匹配的城市,而且还没选省份,
// 尝试遍历所有省份找到包含该城市的
if (!province) {
emitLog(` 城市 "${city}" 未找到,尝试遍历省份...`);
const leftCol = cols.nth(0);
const provItems = leftCol.locator('[class*="provItem"]');
const provCount = await provItems.count();
for (let i = 1; i < provCount; i++) { // 跳过"全国"
await provItems.nth(i).click();
await sleep(600);
const updatedCityItems = rightCol.locator('[class*="provItem"]');
const updatedCount = await updatedCityItems.count();
for (let j = 0; j < updatedCount; j++) {
const ct = await updatedCityItems.nth(j).textContent().catch(() => '');
if (ct.trim() === city) {
await updatedCityItems.nth(j).click();
cityClicked = true;
const provName = await provItems.nth(i).textContent().catch(() => '');
emitLog(` 找到城市: ${provName}${city}`);
break;
}
}
if (cityClicked) break;
}
if (!cityClicked) {
emitLog(` ⚠️ 所有省份中未找到城市: "${city}",将使用当前省份级筛选`);
}
} else {
emitLog(` ⚠️ ${province}下未找到城市: "${city}",将使用省份级筛选`);
}
}
}
}
// Step 4: 点击确认按钮
const confirmBtn = panel.locator('[class*="searchBtn"]').first();
const confirmVisible = await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false);
if (confirmVisible) {
await confirmBtn.click();
emitLog(` ✅ 地区筛选已应用`);
await sleep(2000);
} else {
emitLog(' ⚠️ 未找到确认按钮,尝试点击面板外关闭');
await page.mouse.click(100, 100);
await sleep(1000);
}
}
async function scrapeCurrentPage(page) {
await autoScroll(page);
return page.evaluate(() => {
const items = [];
const cards = document.querySelectorAll('a[class*="feeds-item-wrap"]');
cards.forEach(card => {
const href = card.getAttribute('href') || '';
const idMatch = href.match(/[?&]id=(\d+)/);
if (!idMatch) return;
const titleEl = card.querySelector('[class*="row1-wrap-title"]');
const priceEl = card.querySelector('[class*="price-wrap"]');
const descEl = card.querySelector('[class*="price-desc"]');
const sellerWrap = card.querySelector('[class*="seller-text-wrap"]');
const imgEl = card.querySelector('img[class*="feeds-image"]');
items.push({
id: idMatch[1],
href: href.startsWith('http') ? href : `https://www.goofish.com${href}`,
title: titleEl?.getAttribute('title') || titleEl?.textContent?.trim() || '',
price: priceEl?.textContent?.trim() || '',
priceDesc: descEl?.textContent?.trim() || '',
sellerLocation: sellerWrap?.getAttribute('title') || '',
image: imgEl?.src || '',
});
});
return items;
});
}
async function autoScroll(page) {
await page.evaluate(async () => {
await new Promise(resolve => {
let scrolled = 0;
const maxScroll = document.body.scrollHeight;
const step = 400 + Math.random() * 300;
const timer = setInterval(() => {
window.scrollBy(0, step);
scrolled += step;
if (scrolled >= maxScroll) {
clearInterval(timer);
resolve();
}
}, 200 + Math.random() * 300);
setTimeout(() => { clearInterval(timer); resolve(); }, 8000);
});
});
await sleep(1000);
}
async function goNextPage(page) {
try {
const nextBtns = page.locator('button[class*="search-pagination-arrow-container"]');
const count = await nextBtns.count();
if (count >= 2) {
const nextBtn = nextBtns.nth(1);
const disabled = await nextBtn.getAttribute('disabled');
if (disabled !== null) return false;
await nextBtn.click();
await sleep(3000);
return true;
}
return false;
} catch {
return false;
}
}
+579
View File
@@ -0,0 +1,579 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { genId, timestamp } from './utils.mjs';
import { checkLogin } from './login.mjs';
import { searchProducts } from './search.mjs';
import { coarseFilter, fineFilter } from './filter.mjs';
import { buildAnalysis } from './analyzer.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, '..', 'data');
const TASKS_FILE = path.join(DATA_DIR, 'tasks.json');
const STAGES = ['login', 'searching', 'coarse_filter', 'fine_filter'];
export class TaskManager {
constructor(broadcast) {
this.tasks = new Map();
this.broadcast = broadcast;
this._loadFromDisk();
this._startScheduler();
}
_buildTaskConfig(config = {}) {
const existingSchedule = config.schedule || {};
const scheduleEnabled = config.scheduleEnabled ?? existingSchedule.enabled ?? false;
const scheduleIntervalMinutes = config.scheduleIntervalMinutes ?? existingSchedule.intervalMinutes ?? 0;
return {
queries: Array.isArray(config.queries) ? config.queries.map(q => String(q).trim()).filter(Boolean) : [],
priceMin: config.priceMin === '' ? null : (config.priceMin ?? null),
priceMax: config.priceMax === '' ? null : (config.priceMax ?? null),
region: config.region || '',
personalSeller: !!config.personalSeller,
customRequirements: config.customRequirements || '',
coarsePrompt: config.coarsePrompt || '',
finePrompt: config.finePrompt || '',
maxPages: Math.max(1, Math.min(20, Number(config.maxPages || 3))),
maxItems: Math.max(1, Math.min(500, Number(config.maxItems || 60))),
schedule: {
enabled: !!scheduleEnabled,
intervalMinutes: Math.max(0, Number(scheduleIntervalMinutes || 0) || 0),
nextRunAt: config.scheduleNextRunAt ?? existingSchedule.nextRunAt ?? null,
lastRunAt: config.scheduleLastRunAt ?? existingSchedule.lastRunAt ?? null,
},
};
}
createTask(config) {
const id = genId();
const task = {
id,
name: config.name || `任务-${id}`,
config: this._buildTaskConfig(config),
stage: 'pending',
running: false,
stopped: false,
products: { raw: [], coarseFiltered: [], fineFiltered: [] },
hermesAnalysis: null,
chatSessions: [],
chatSessionsFull: [],
logs: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
this._normalizeSchedule(task);
this.tasks.set(id, task);
this._persistToDisk();
this._emit(id, 'task:created', this._serialize(task));
return task;
}
duplicateTask(sourceId) {
const source = this.tasks.get(sourceId);
if (!source) return null;
const id = genId();
const task = {
id,
name: `${source.name} (副本)`,
config: JSON.parse(JSON.stringify(source.config || {})),
stage: 'pending',
running: false,
stopped: false,
products: { raw: [], coarseFiltered: [], fineFiltered: [] },
hermesAnalysis: null,
chatSessions: [],
chatSessionsFull: [],
logs: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
this._normalizeSchedule(task);
this.tasks.set(id, task);
this._persistToDisk();
this._emit(id, 'task:created', this._serialize(task));
return task;
}
updateTask(id, patch = {}) {
const task = this.tasks.get(id);
if (!task) return null;
if (task.running) return null;
task.name = patch.name || task.name;
task.config = { ...task.config, ...this._buildTaskConfig({ ...task.config, ...patch }) };
task.updatedAt = Date.now();
this._normalizeSchedule(task);
this._persistToDisk();
this._emit(id, 'task:updated', this._serialize(task));
return task;
}
_resetTaskProducts(task) {
task.products = { raw: [], coarseFiltered: [], fineFiltered: [] };
task.hermesAnalysis = null;
task.chatSessions = [];
task.chatSessionsFull = [];
task.stage = 'pending';
task.stopped = false;
}
async startTask(id, configUpdates = {}) {
const task = this.tasks.get(id);
if (!task || task.running) return;
if (Object.keys(configUpdates || {}).length) {
const { reset, scheduled, ...patch } = configUpdates;
if (Object.keys(patch).length) task.config = { ...task.config, ...this._buildTaskConfig({ ...task.config, ...patch }) };
if (reset) this._resetTaskProducts(task);
if (scheduled) {
task.config.schedule.lastRunAt = Date.now();
this._normalizeSchedule(task, true);
}
}
task.running = true;
task.stopped = false;
this._persistToDisk();
this._emit(id, 'task:started', { id });
const emitLog = (msg) => {
const entry = { time: timestamp(), message: msg };
task.logs.push(entry);
this._emit(id, 'task:log', entry);
};
const shouldStop = () => task.stopped;
try {
await this._runPipeline(task, emitLog, shouldStop);
if (!task.stopped) {
task.stage = 'completed';
}
} catch (err) {
emitLog(`❌ 任务异常: ${err.message}`);
} finally {
task.running = false;
if (task.stopped) {
task.stage = 'stopped';
}
this._persistToDisk();
this._emit(id, 'task:finished', this._serialize(task));
}
}
stopTask(id) {
const task = this.tasks.get(id);
if (!task) return;
task.stopped = true;
task.stage = 'stopping';
this._persistToDisk();
this._emit(id, 'task:stopping', { id });
}
deleteTask(id) {
const task = this.tasks.get(id);
if (task) {
task.stopped = true;
this.tasks.delete(id);
this._persistToDisk();
}
}
clearTaskCache(id, { mode = 'all', minScore = 75 } = {}) {
const task = this.tasks.get(id);
if (!task || task.running) return null;
const before = {
raw: task.products?.raw?.length || 0,
coarseFiltered: task.products?.coarseFiltered?.length || 0,
fineFiltered: task.products?.fineFiltered?.length || 0,
hermesRecommended: task.hermesAnalysis?.recommendedCount || 0,
};
let kept = [];
const keepMode = mode === 'hermes-recommend' || mode === 'hermes-pass' ? mode : 'all';
if (keepMode !== 'all') {
const fine = task.products?.fineFiltered || [];
kept = fine.filter(item => {
const analysis = item.hermesAnalysis;
if (!analysis) return false;
if (keepMode === 'hermes-recommend') return analysis.verdict === 'recommend';
return (analysis.verdict === 'recommend' || analysis.verdict === 'caution') && Number(analysis.score || 0) >= Number(minScore || 0);
});
}
task.products = keepMode === 'all'
? { raw: [], coarseFiltered: [], fineFiltered: [] }
: { raw: kept, coarseFiltered: kept, fineFiltered: kept };
task.hermesAnalysis = null;
task.chatSessions = [];
task.chatSessionsFull = [];
task.stage = keepMode === 'all' ? 'pending' : (kept.length ? 'completed' : 'pending');
task.stopped = false;
task.updatedAt = Date.now();
task.logs.push({
time: timestamp(),
message: keepMode === 'all'
? `🧹 已清除本任务缓存:采集 ${before.raw} / 粗筛 ${before.coarseFiltered} / 细筛 ${before.fineFiltered}`
: `🧹 已清理缓存并保留 ${kept.length} 个优质结果(模式:${keepMode},最低分:${minScore}`,
});
this._persistToDisk();
this._emit(id, 'task:updated', this._serialize(task));
return {
ok: true,
mode: keepMode,
minScore: Number(minScore || 0),
before,
after: {
raw: task.products.raw.length,
coarseFiltered: task.products.coarseFiltered.length,
fineFiltered: task.products.fineFiltered.length,
},
kept: kept.length,
task: this._serialize(task),
};
}
getTask(id) {
const task = this.tasks.get(id);
return task ? this._serialize(task) : null;
}
getTaskFull(id) {
return this.tasks.get(id) || null;
}
analyzeTask(id, options = {}) {
const task = this.tasks.get(id);
if (!task) return null;
const analysis = buildAnalysis(task, options);
this.setTaskAnalysis(id, analysis);
return analysis;
}
setTaskAnalysis(id, analysis) {
const task = this.tasks.get(id);
if (!task || !analysis) return null;
const map = new Map((analysis.items || []).map(item => [item.id, item]));
task.products.fineFiltered = (task.products.fineFiltered || []).map(item => map.get(item.id) || item);
task.hermesAnalysis = analysis.summary;
task.updatedAt = Date.now();
this._persistToDisk();
this._emit(id, 'task:updated', this._serialize(task));
return task;
}
getAllTasks() {
return [...this.tasks.values()].map(t => this._serialize(t));
}
_resolveResumeStage(task) {
if (task.products.fineFiltered.length > 0) return 'fine_filter';
if (task.products.coarseFiltered.length > 0) return 'fine_filter';
if (task.products.raw.length > 0) return 'coarse_filter';
return null;
}
async _runPipeline(task, emitLog, shouldStop) {
const resumeStage = this._resolveResumeStage(task);
const stageOrder = ['login', 'searching', 'coarse_filter', 'fine_filter'];
const resumeIdx = resumeStage ? stageOrder.indexOf(resumeStage) : -1;
if (resumeStage) {
emitLog(`♻️ 检测到历史数据,从「${resumeStage}」阶段恢复`);
emitLog(` 已有: ${task.products.raw.length}采集 / ${task.products.coarseFiltered.length}粗筛 / ${task.products.fineFiltered.length}候选`);
}
// Stage 1: Login (always)
task.stage = 'login';
this._emitStage(task);
emitLog('========== 阶段1: 登录验证 ==========');
const loggedIn = await checkLogin(emitLog);
if (!loggedIn || shouldStop()) return;
const filterRequirements = this._buildFilterRequirements(task.config);
// Stage 2: Search
if (resumeIdx >= stageOrder.indexOf('searching')) {
emitLog(`⏭️ 跳过搜索采集(已有 ${task.products.raw.length} 个商品)`);
} else {
task.stage = 'searching';
this._emitStage(task);
emitLog('========== 阶段2: 搜索采集 ==========');
const filters = {
priceMin: task.config.priceMin,
priceMax: task.config.priceMax,
region: task.config.region,
personalSeller: task.config.personalSeller,
};
for (const query of task.config.queries) {
if (shouldStop()) break;
const products = await searchProducts(query, filters, task.config.maxPages, emitLog, shouldStop);
const deduped = products.filter(p => !task.products.raw.some(e => e.id === p.id));
const remaining = Math.max(0, (task.config.maxItems || 60) - task.products.raw.length);
const accepted = deduped.slice(0, remaining);
task.products.raw.push(...accepted);
this._emitProducts(task);
if (task.products.raw.length >= (task.config.maxItems || 60)) {
emitLog(`📦 已达到单次最大商品寻找量 ${task.config.maxItems || 60},停止继续搜索`);
break;
}
}
emitLog(`📊 总计采集 ${task.products.raw.length} 个商品(去重后)`);
this._persistToDisk();
}
if (shouldStop() || task.products.raw.length === 0) return;
// Stage 3: Coarse Filter
if (resumeIdx >= stageOrder.indexOf('coarse_filter')) {
emitLog(`⏭️ 跳过粗筛(已有 ${task.products.coarseFiltered.length} 个通过)`);
} else {
task.stage = 'coarse_filter';
this._emitStage(task);
emitLog('========== 阶段3: 粗筛(标题筛选) ==========');
const coarseRequirements = this._buildCoarseRequirements(task.config);
task.products.coarseFiltered = await coarseFilter(
task.products.raw, coarseRequirements, emitLog, shouldStop, task.config.coarsePrompt
);
this._emitProducts(task);
this._persistToDisk();
}
if (shouldStop() || task.products.coarseFiltered.length === 0) return;
// Stage 4: Fine Filter
if (resumeIdx >= stageOrder.indexOf('fine_filter')) {
emitLog(`⏭️ 跳过细筛(已有 ${task.products.fineFiltered.length} 个通过)`);
} else {
task.stage = 'fine_filter';
this._emitStage(task);
emitLog('========== 阶段4: 细筛(详情页筛选) ==========');
task.products.fineFiltered = await fineFilter(
task.products.coarseFiltered.slice(0, task.config.maxItems || 60), filterRequirements, emitLog, shouldStop, task.config.finePrompt
);
this._emitProducts(task);
this._persistToDisk();
}
if (shouldStop()) return;
task.chatSessions = [];
task.chatSessionsFull = [];
this._emit(task.id, 'task:chats', task.chatSessions);
this._persistToDisk();
emitLog(`🦐 已关闭联系卖家功能:保留 ${task.products.fineFiltered.length} 个候选商品,后续由 Hermes 读取 data/tasks.json 做深度分析报告`);
emitLog('========== 采集流程结束 ==========');
}
_buildCoarseRequirements(config) {
const parts = [];
if (config.queries.length > 0) {
parts.push(`目标商品: ${config.queries.join(', ')}`);
}
if (config.customRequirements) {
parts.push(`补充需求: ${config.customRequirements}`);
}
return parts.join('\n');
}
_buildFilterRequirements(config) {
const parts = [];
if (config.queries.length > 0) {
parts.push(`搜索关键词: ${config.queries.join(', ')}`);
}
if (config.priceMin != null || config.priceMax != null) {
parts.push(`价格范围: ${config.priceMin ?? '不限'} - ${config.priceMax ?? '不限'}`);
}
if (config.region) {
parts.push(`地区偏好: ${config.region}`);
}
if (config.personalSeller) {
parts.push('只要个人卖家');
}
if (config.customRequirements) {
parts.push(`商品需求: ${config.customRequirements}`);
}
return parts.join('\n');
}
_serialize(task) {
return {
id: task.id,
name: task.name,
config: task.config,
stage: task.stage,
running: task.running,
stopped: task.stopped,
products: {
rawCount: task.products.raw.length,
coarseCount: task.products.coarseFiltered.length,
fineCount: task.products.fineFiltered.length,
raw: task.products.raw.slice(0, 100),
coarseFiltered: task.products.coarseFiltered.slice(0, 50),
fineFiltered: task.products.fineFiltered,
},
chatSessions: task.chatSessions || [],
hermesAnalysis: task.hermesAnalysis || null,
logs: task.logs.slice(-200),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
};
}
_serializeFull(task) {
return {
id: task.id,
name: task.name,
config: task.config,
stage: task.stage,
products: {
raw: task.products.raw,
coarseFiltered: task.products.coarseFiltered,
fineFiltered: task.products.fineFiltered,
},
chatSessions: task.chatSessions || [],
chatSessionsFull: task.chatSessionsFull || [],
hermesAnalysis: task.hermesAnalysis || null,
logs: task.logs.slice(-500),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
};
}
_persistToDisk() {
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
const data = [...this.tasks.values()].map(t => this._serializeFull(t));
fs.writeFileSync(TASKS_FILE, JSON.stringify(data, null, 2), 'utf-8');
} catch (err) {
console.error('持久化任务失败:', err.message);
}
}
_loadFromDisk() {
try {
if (!fs.existsSync(TASKS_FILE)) return;
const raw = fs.readFileSync(TASKS_FILE, 'utf-8');
const data = JSON.parse(raw);
for (const t of data) {
let chatSessionsFull = t.chatSessionsFull || [];
if (chatSessionsFull.length === 0 && (t.chatSessions || []).length > 0) {
const fineMap = new Map((t.products?.fineFiltered || []).map(p => [p.id, p]));
chatSessionsFull = t.chatSessions.map(s => {
const product = fineMap.get(s.id) || {
id: s.id,
title: s.productTitle || '',
price: s.productPrice || '',
description: s.productDescription || '',
sellerName: s.sellerName || '',
chatUrl: '',
};
const messages = (s.messages || []).map(m => ({
...m,
fingerprint: `${m.role}:${m.content?.trim()}`,
}));
const chatHistory = messages.map(m => ({
role: m.role === 'self' ? 'assistant' : 'user',
content: m.content,
}));
return {
id: s.id,
product,
chatContext: null,
status: s.status || 'waiting',
goalReason: s.goalReason || '',
messages,
chatHistory,
lastChecked: s.lastChecked || Date.now(),
backoffLevel: 0,
};
});
console.log(` 🔄 任务 ${t.id}: 从旧格式迁移了 ${chatSessionsFull.length} 个聊天会话`);
}
const task = {
id: t.id,
name: t.name,
config: this._buildTaskConfig(t.config || {}),
stage: (t.stage === 'completed' || t.stage === 'stopped') ? t.stage : 'stopped',
running: false,
stopped: true,
products: {
raw: t.products?.raw || [],
coarseFiltered: t.products?.coarseFiltered || [],
fineFiltered: t.products?.fineFiltered || [],
},
chatSessions: t.chatSessions || [],
chatSessionsFull,
hermesAnalysis: t.hermesAnalysis || null,
logs: t.logs || [],
createdAt: t.createdAt || Date.now(),
updatedAt: t.updatedAt || t.createdAt || Date.now(),
};
this._normalizeSchedule(task);
this.tasks.set(task.id, task);
}
console.log(`📂 从磁盘恢复了 ${data.length} 个任务`);
} catch (err) {
console.error('加载持久化任务失败:', err.message);
}
}
_normalizeSchedule(task, afterRun = false) {
if (!task.config.schedule) {
task.config.schedule = { enabled: false, intervalMinutes: 0, nextRunAt: null, lastRunAt: null };
}
const s = task.config.schedule;
s.intervalMinutes = Number(s.intervalMinutes || 0) || 0;
s.enabled = !!s.enabled && s.intervalMinutes > 0;
if (!s.enabled) {
s.nextRunAt = null;
return;
}
const base = afterRun ? Date.now() : (Number(s.nextRunAt) || Date.now());
if (!s.nextRunAt || Number(s.nextRunAt) <= Date.now()) {
s.nextRunAt = base + s.intervalMinutes * 60 * 1000;
}
}
_startScheduler() {
this.schedulerTimer = setInterval(() => this._tickSchedules(), 30000);
}
_tickSchedules() {
const now = Date.now();
for (const task of this.tasks.values()) {
const sched = task.config?.schedule;
if (!sched?.enabled || task.running || !sched.nextRunAt || sched.nextRunAt > now) continue;
task.logs.push({ time: timestamp(), message: `⏰ 定时任务触发,开始后台采集` });
this.startTask(task.id, { reset: true, scheduled: true });
}
}
_emit(taskId, event, data) {
this.broadcast({ event, taskId, data });
}
_emitStage(task) {
this._emit(task.id, 'task:stage', { id: task.id, stage: task.stage });
}
_emitProducts(task) {
this._emit(task.id, 'task:products', {
id: task.id,
rawCount: task.products.raw.length,
coarseCount: task.products.coarseFiltered.length,
fineCount: task.products.fineFiltered.length,
});
}
}
+78
View File
@@ -0,0 +1,78 @@
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function randomDelay(minMs, maxMs) {
const ms = minMs + Math.random() * (maxMs - minMs);
return sleep(ms);
}
export function randomInt(min, max) {
return Math.floor(min + Math.random() * (max - min));
}
export function genId() {
return crypto.randomUUID().slice(0, 8);
}
export function parsePrice(text) {
if (!text) return null;
const match = text.replace(/,/g, '').match(/[\d.]+/);
return match ? parseFloat(match[0]) : null;
}
export function extractItemId(url) {
const match = url.match(/[?&]id=(\d+)/);
return match ? match[1] : null;
}
export function extractUserId(url) {
const match = url.match(/[?&](?:userId|peerUserId)=(\d+)/);
return match ? match[1] : null;
}
export function timestamp() {
return new Date().toLocaleTimeString('zh-CN', { hour12: false });
}
/**
* 通用真人校验检测:检查当前页面是否触发了平台验证。
* 如果检测到验证页面,会持续轮询等待用户手动完成(最长 maxWaitMs)。
* @returns {Promise<boolean>} true=校验通过或无校验,false=超时或任务停止
*/
export async function waitIfVerification(page, { emitLog, shouldStop, label = '', maxWaitMs = 300000 } = {}) {
const prefix = label ? `[${label}] ` : '';
const checkInterval = 5000;
let waited = 0;
while (waited < maxWaitMs) {
const hasVerify = await page.evaluate(() => {
const bodyText = document.body?.innerText || '';
const url = location.href;
if (url.includes('verify') || url.includes('captcha') || url.includes('checkcode')) return true;
if (bodyText.includes('安全验证') || bodyText.includes('滑动验证')
|| bodyText.includes('请完成验证') || bodyText.includes('人机验证')
|| bodyText.includes('拖动滑块') || bodyText.includes('点击完成验证')) return true;
const iframe = document.querySelector('iframe[src*="captcha"], iframe[src*="verify"]');
if (iframe) return true;
return false;
}).catch(() => false);
if (!hasVerify) return true;
if (waited === 0) {
if (emitLog) emitLog(` ${prefix}🛑 检测到平台真人校验!请在浏览器中手动完成验证,完成后自动继续...`);
}
await sleep(checkInterval);
waited += checkInterval;
if (shouldStop && shouldStop()) {
if (emitLog) emitLog(` ${prefix}任务已停止,中断校验等待`);
return false;
}
}
if (emitLog) emitLog(` ${prefix}⚠️ 等待校验超时(${Math.round(maxWaitMs / 60000)}分钟),跳过本次`);
return false;
}