189 lines
7.9 KiB
JavaScript
189 lines
7.9 KiB
JavaScript
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 };
|
||
}
|