/** * 文本提取 - 标题、价格、参数表、卖点、描述、品牌 * 从 extension-v1 移植(DOM 兜底路径) */ import type { SiteProfile, TextRule } from '../profiles/types'; export interface TextMaterial { kind: TextRule['kind']; content: string; pairs?: Array<{ key: string; value: string }>; // table 模式的结构化结果 } function clean(s: string): string { return s.replace(/\s+/g, ' ').trim(); } function extractOne(rule: TextRule): TextMaterial | null { for (const sel of rule.selectors) { let nodes: NodeListOf; try { nodes = document.querySelectorAll(sel); } catch { continue; } if (!nodes.length) continue; // table 模式:参数表 if (rule.extract === 'table') { const pairs: Array<{ key: string; value: string }> = []; nodes.forEach((row) => { const k = clean(row.querySelector(rule.tableKeySelector ?? '')?.textContent ?? ''); const v = clean(row.querySelector(rule.tableValueSelector ?? '')?.textContent ?? ''); if (k && v) pairs.push({ key: k.replace(/[::]$/, ''), value: v }); }); if (pairs.length) { return { kind: rule.kind, content: pairs.map((p) => `${p.key}: ${p.value}`).join('\n'), pairs, }; } continue; } // join 模式:标题被拆成多个 span if (rule.extract === 'join') { let text = ''; nodes.forEach((n) => { text += n.textContent ?? ''; }); text = clean(text); if (text) return { kind: rule.kind, content: text }; continue; } // first 模式:只取第一个 const first = clean(nodes[0].textContent ?? ''); if (first) return { kind: rule.kind, content: first }; } return null; } export function collectTexts(profile: SiteProfile): { materials: TextMaterial[]; missingRequired: string[]; } { const materials: TextMaterial[] = []; const missingRequired: string[] = []; for (const rule of profile.textRules) { const m = extractOne(rule); if (m) materials.push(m); else if (rule.required) missingRequired.push(rule.kind); } return { materials, missingRequired }; } /** 合并去重:以 kind 为键,结构化来源优先,DOM 来源兜底。 * 参数表(params)特殊处理:两边的 pairs 做并集合并(按 key 去重), * 因为「关于商品」只给前几项,完整「特征」在 DOM 里,需要合并才能拿全。 */ export function mergeTexts( primary: TextMaterial[], fallback: TextMaterial[] ): TextMaterial[] { const map = new Map(); for (const m of [...primary, ...fallback]) { if (m.kind === 'params') { const existing = map.get('params'); if (!existing) { map.set('params', { ...m, pairs: [...(m.pairs ?? [])] }); } else { const merged = [...(existing.pairs ?? [])]; const seen = new Set(merged.map((p) => p.key)); for (const p of m.pairs ?? []) { if (!seen.has(p.key)) { merged.push(p); seen.add(p.key); } } existing.pairs = merged; existing.content = merged.map((p) => `${p.key}: ${p.value}`).join('\n'); } continue; } if (!map.has(m.kind)) map.set(m.kind, m); } return Array.from(map.values()); }