Files
xianyu-hunter-hermes-skill/project/xianyu-hunter/lib/analyzer.mjs
T

190 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { buildItemLinks } from './links.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const REPORT_DIR = path.join(ROOT, 'reports');
export function priceNumber(value) {
const m = String(value || '').replace(/,/g, '').match(/\d+(?:\.\d+)?/);
return m ? Number(m[0]) : null;
}
function normalize(value = '') {
return String(value || '').toLowerCase().replace(/\s+/g, '');
}
function queryTokens(task) {
const text = (task.config?.queries || []).join(' ').toLowerCase();
const tokens = text
.split(/[^a-z0-9\u4e00-\u9fa5]+/i)
.map(t => t.trim())
.filter(t => t.length >= 2);
return [...new Set(tokens)];
}
export function analyzeItem(item, task) {
const text = normalize(`${item.title || ''} ${item.description || ''}`);
const title = normalize(item.title || '');
const flags = [];
const positives = [];
let score = 60;
const badRules = [
['维修', '疑似维修/故障'], ['求购', '疑似求购'], ['租', '疑似租赁'], ['回收', '疑似回收'],
['展示', '疑似展示/仅展示'], ['配件', '疑似配件'], ['散热器', '疑似散热器/配件'],
['主板', '可能是主板/套装'], ['整机', '可能是整机非单件'], ['套装', '可能是套装'],
['坏', '疑似故障'], ['不亮', '疑似无法点亮'], ['暗病', '疑似暗病'], ['不能进系统', '无法进系统/疑似故障'], ['不能进系統', '无法进系统/疑似故障'],
];
for (const [word, label] of badRules) {
if (text.includes(word)) flags.push(label);
}
const wantsE31245v3 = /e3\s*-?\s*1245\s*[_-]?\s*v3/i.test((task.config?.queries || []).join(' '));
if (wantsE31245v3) {
const exactTarget = /e3\s*-?\s*1245\s*[_-]?\s*v3/i;
const otherE3v3 = /e3\s*-?\s*(12\d{2})\s*[_-]?\s*v3/ig;
const titleRaw = String(item.title || '');
const textRaw = `${item.title || ''} ${item.description || ''}`;
const models = [...textRaw.matchAll(otherE3v3)].map(m => m[1]);
const titleModels = [...titleRaw.matchAll(otherE3v3)].map(m => m[1]);
if (!exactTarget.test(textRaw)) {
score -= 35;
flags.push('未明确命中 E3-1245v3');
}
const wrongTitleModels = titleModels.filter(m => m !== '1245');
if (wrongTitleModels.length) {
score -= 35;
flags.push(`标题主型号疑似非1245v3E3-${[...new Set(wrongTitleModels)].join('/')}v3`);
} else if (models.some(m => m !== '1245') && !exactTarget.test(titleRaw)) {
score -= 18;
flags.push('详情/关联词含其他 E3 v3 型号,需核对主型号');
}
}
const tokens = queryTokens(task);
const matched = tokens.filter(t => title.includes(normalize(t)) || text.includes(normalize(t)));
const importantTokens = tokens.filter(t => /[a-z]*\d/.test(t) || /v\d/i.test(t));
const missingImportant = importantTokens.filter(t => !text.includes(normalize(t)));
if (matched.length > 0) {
score += Math.min(15, matched.length * 4);
positives.push(`命中关键词:${matched.slice(0, 4).join('/')}`);
}
if (missingImportant.length >= Math.max(1, Math.ceil(importantTokens.length / 2))) {
score -= 20;
flags.push(`关键型号不完整:缺 ${missingImportant.slice(0, 4).join('/')}`);
}
const price = priceNumber(item.detailPrice || item.price);
const min = task.config?.priceMin;
const max = task.config?.priceMax;
if (price != null) {
if (max != null && price > max) { score -= 25; flags.push(`超预算:${price} > ${max}`); }
else if (max != null && price <= max) { score += 8; positives.push(`价格在预算内:${price}`); }
if (min != null && price < min) { score -= 8; flags.push(`价格偏低需防异常:${price} < ${min}`); }
} else {
score -= 5;
flags.push('价格未识别');
}
if (item.description && String(item.description).trim().length >= 30) {
score += 8;
positives.push('描述较完整');
} else {
score -= 8;
flags.push('详情描述偏少');
}
if (item.images?.length) {
score += Math.min(6, item.images.length * 2);
positives.push(`图片${item.images.length}`);
}
if (item.sellerRating) positives.push(item.sellerRating);
if (item.wantCount && /\d/.test(item.wantCount)) flags.push(`热度:${item.wantCount}`);
if (item.localFineReason) positives.push(`本地细筛:${item.localFineReason}`);
score -= Math.min(40, [...new Set(flags)].filter(f => /疑似|可能|超预算|故障|/.test(f)).length * 10);
score = Math.max(0, Math.min(100, score));
let verdict = 'caution';
if (score >= 75 && !flags.some(f => /疑似|可能|超预算|故障|关键型号/.test(f))) verdict = 'recommend';
if (score < 45 || flags.some(f => /求购|维修|故障|超预算|关键型号/.test(f))) verdict = 'reject';
const reason = verdict === 'recommend'
? `匹配度较高,${positives.slice(0, 3).join('') || '暂无明显风险'}`
: verdict === 'caution'
? `需要人工核对:${[...new Set(flags)].slice(0, 3).join('') || '信息不足'}`
: `暂不推荐:${[...new Set(flags)].slice(0, 3).join('') || '综合分偏低'}`;
return {
score,
verdict,
reason,
flags: [...new Set(flags)].slice(0, 8),
positives: [...new Set(positives)].slice(0, 8),
analyzedAt: Date.now(),
};
}
export function buildAnalysis(task, { limit = 50 } = {}) {
const candidates = task.products?.fineFiltered || [];
const analyzed = candidates.map(item => ({ ...item, hermesAnalysis: analyzeItem(item, task) }));
const sorted = [...analyzed].sort((a, b) => (b.hermesAnalysis?.score || 0) - (a.hermesAnalysis?.score || 0));
const recommended = sorted.filter(i => i.hermesAnalysis.verdict === 'recommend');
const caution = sorted.filter(i => i.hermesAnalysis.verdict === 'caution');
const rejected = sorted.filter(i => i.hermesAnalysis.verdict === 'reject');
const summary = {
taskId: task.id,
taskName: task.name,
analyzedAt: Date.now(),
total: candidates.length,
recommendedCount: recommended.length,
cautionCount: caution.length,
rejectedCount: rejected.length,
hasGoodResults: recommended.length > 0,
topItems: sorted.slice(0, limit).map(item => ({
id: item.id,
title: item.title,
price: item.detailPrice || item.price || '',
location: item.sellerLocation || '',
href: buildItemLinks(item).primaryUrl || 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 };
}