feat: publish xianyu hunter hermes skill package
This commit is contained in:
@@ -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 : ['你好呀~'];
|
||||
}
|
||||
}
|
||||
@@ -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(`标题主型号疑似非1245v3:E3-${[...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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user