hangwin--mcp-chrome
198 行
5.8 KiB
TypeScript
198 行
5.8 KiB
TypeScript
/**
|
|
* Selector Stability - 选择器稳定性评估
|
|
*/
|
|
|
|
import type {
|
|
SelectorCandidate,
|
|
SelectorStability,
|
|
SelectorStabilitySignals,
|
|
SelectorType,
|
|
} from './types';
|
|
import { splitCompositeSelector } from './types';
|
|
|
|
const TESTID_ATTR_NAMES = [
|
|
'data-testid',
|
|
'data-test-id',
|
|
'data-test',
|
|
'data-qa',
|
|
'data-cy',
|
|
] as const;
|
|
|
|
function clamp01(n: number): number {
|
|
if (!Number.isFinite(n)) return 0;
|
|
return Math.min(1, Math.max(0, n));
|
|
}
|
|
|
|
function mergeSignals(
|
|
a: SelectorStabilitySignals,
|
|
b: SelectorStabilitySignals,
|
|
): SelectorStabilitySignals {
|
|
return {
|
|
usesId: a.usesId || b.usesId || undefined,
|
|
usesTestId: a.usesTestId || b.usesTestId || undefined,
|
|
usesAria: a.usesAria || b.usesAria || undefined,
|
|
usesText: a.usesText || b.usesText || undefined,
|
|
usesNthOfType: a.usesNthOfType || b.usesNthOfType || undefined,
|
|
usesAttributes: a.usesAttributes || b.usesAttributes || undefined,
|
|
usesClass: a.usesClass || b.usesClass || undefined,
|
|
};
|
|
}
|
|
|
|
function analyzeCssLike(selector: string): SelectorStabilitySignals {
|
|
const s = String(selector || '');
|
|
const usesNthOfType = /:nth-of-type\(/i.test(s);
|
|
const usesAttributes = /\[[^\]]+\]/.test(s);
|
|
const usesAria = /\[\s*aria-[^=]+\s*=|\[\s*role\s*=|\brole\s*=\s*/i.test(s);
|
|
|
|
// Avoid counting `#` inside attribute values (e.g. href="#...") by requiring a token-ish pattern.
|
|
const usesId = /(^|[\s>+~])#[^\s>+~.:#[]+/.test(s);
|
|
const usesClass = /(^|[\s>+~])\.[^\s>+~.:#[]+/.test(s);
|
|
|
|
const lower = s.toLowerCase();
|
|
const usesTestId = TESTID_ATTR_NAMES.some((a) => lower.includes(`[${a}`));
|
|
|
|
return {
|
|
usesId: usesId || undefined,
|
|
usesTestId: usesTestId || undefined,
|
|
usesAria: usesAria || undefined,
|
|
usesNthOfType: usesNthOfType || undefined,
|
|
usesAttributes: usesAttributes || undefined,
|
|
usesClass: usesClass || undefined,
|
|
};
|
|
}
|
|
|
|
function baseScoreForCssSignals(signals: SelectorStabilitySignals): number {
|
|
if (signals.usesTestId) return 0.95;
|
|
if (signals.usesId) return 0.9;
|
|
if (signals.usesAria) return 0.8;
|
|
if (signals.usesAttributes) return 0.75;
|
|
if (signals.usesClass) return 0.65;
|
|
return 0.5;
|
|
}
|
|
|
|
function lengthPenalty(value: string): number {
|
|
const len = value.length;
|
|
if (len <= 60) return 0;
|
|
if (len <= 120) return 0.05;
|
|
if (len <= 200) return 0.1;
|
|
return 0.18;
|
|
}
|
|
|
|
/**
|
|
* 计算选择器稳定性评分
|
|
*/
|
|
export function computeSelectorStability(candidate: SelectorCandidate): SelectorStability {
|
|
if (candidate.type === 'css' || candidate.type === 'attr') {
|
|
const composite = splitCompositeSelector(candidate.value);
|
|
if (composite) {
|
|
const a = analyzeCssLike(composite.frameSelector);
|
|
const b = analyzeCssLike(composite.innerSelector);
|
|
const merged = mergeSignals(a, b);
|
|
|
|
let score = baseScoreForCssSignals(merged);
|
|
score -= 0.05; // iframe coupling penalty
|
|
if (merged.usesNthOfType) score -= 0.2;
|
|
score -= lengthPenalty(candidate.value);
|
|
|
|
return { score: clamp01(score), signals: merged, note: 'composite' };
|
|
}
|
|
|
|
const signals = analyzeCssLike(candidate.value);
|
|
let score = baseScoreForCssSignals(signals);
|
|
if (signals.usesNthOfType) score -= 0.2;
|
|
score -= lengthPenalty(candidate.value);
|
|
|
|
return { score: clamp01(score), signals };
|
|
}
|
|
|
|
if (candidate.type === 'xpath') {
|
|
const s = String(candidate.value || '');
|
|
const signals: SelectorStabilitySignals = {
|
|
usesAttributes: /@[\w-]+\s*=/.test(s) || undefined,
|
|
usesId: /@id\s*=/.test(s) || undefined,
|
|
usesTestId: /@data-testid\s*=/.test(s) || undefined,
|
|
};
|
|
|
|
let score = 0.42;
|
|
if (signals.usesTestId) score = 0.85;
|
|
else if (signals.usesId) score = 0.75;
|
|
else if (signals.usesAttributes) score = 0.55;
|
|
|
|
score -= lengthPenalty(s);
|
|
return { score: clamp01(score), signals };
|
|
}
|
|
|
|
if (candidate.type === 'aria') {
|
|
const hasName = typeof candidate.name === 'string' && candidate.name.trim().length > 0;
|
|
const hasRole = typeof candidate.role === 'string' && candidate.role.trim().length > 0;
|
|
|
|
const signals: SelectorStabilitySignals = { usesAria: true };
|
|
let score = hasName && hasRole ? 0.8 : hasName ? 0.72 : 0.6;
|
|
score -= lengthPenalty(candidate.value);
|
|
|
|
return { score: clamp01(score), signals };
|
|
}
|
|
|
|
// text
|
|
const text = String(candidate.value || '').trim();
|
|
const signals: SelectorStabilitySignals = { usesText: true };
|
|
let score = 0.35;
|
|
|
|
// Very short texts tend to be ambiguous; very long texts are unstable.
|
|
if (text.length >= 6 && text.length <= 48) score = 0.45;
|
|
if (text.length > 80) score = 0.3;
|
|
|
|
return { score: clamp01(score), signals };
|
|
}
|
|
|
|
/**
|
|
* 为选择器候选添加稳定性评分
|
|
*/
|
|
export function withStability(candidate: SelectorCandidate): SelectorCandidate {
|
|
if (candidate.stability) return candidate;
|
|
return { ...candidate, stability: computeSelectorStability(candidate) };
|
|
}
|
|
|
|
function typePriority(type: SelectorType): number {
|
|
switch (type) {
|
|
case 'attr':
|
|
return 5;
|
|
case 'css':
|
|
return 4;
|
|
case 'aria':
|
|
return 3;
|
|
case 'xpath':
|
|
return 2;
|
|
case 'text':
|
|
return 1;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 比较两个选择器候选的优先级
|
|
* 返回负数表示 a 优先,正数表示 b 优先
|
|
*/
|
|
export function compareSelectorCandidates(a: SelectorCandidate, b: SelectorCandidate): number {
|
|
// 1. 用户指定的权重优先
|
|
const aw = a.weight ?? 0;
|
|
const bw = b.weight ?? 0;
|
|
if (aw !== bw) return bw - aw;
|
|
|
|
// 2. 稳定性评分
|
|
const as = a.stability?.score ?? computeSelectorStability(a).score;
|
|
const bs = b.stability?.score ?? computeSelectorStability(b).score;
|
|
if (as !== bs) return bs - as;
|
|
|
|
// 3. 类型优先级
|
|
const ap = typePriority(a.type);
|
|
const bp = typePriority(b.type);
|
|
if (ap !== bp) return bp - ap;
|
|
|
|
// 4. 长度(越短越好)
|
|
const alen = String(a.value || '').length;
|
|
const blen = String(b.value || '').length;
|
|
return alen - blen;
|
|
}
|