111 lines
3.3 KiB
TypeScript
111 lines
3.3 KiB
TypeScript
/**
|
||
* 文本提取 - 标题、价格、参数表、卖点、描述、品牌
|
||
* 从 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<Element>;
|
||
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<string, TextMaterial>();
|
||
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());
|
||
}
|