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(`标题主型号疑似非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: 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 }; }