const MODE_WEIGHTS = Object.freeze({ algo: Object.freeze({ algorithm: 80, structure: 15, metadata: 5, laya: 0 }), hybrid: Object.freeze({ algorithm: 70, structure: 15, metadata: 5, laya: 10 }), laya: Object.freeze({ algorithm: 55, structure: 15, metadata: 5, laya: 25 }) }); const PROTECTED_TOKENS = new Set(['max', 'news', 'premium', 'extra', 'xtra', 'kids', 'movies']); const STRONG_FAMILIES = ['bein sports', 'sky sports', 'canal plus', 'eurosport', 'espn', 'dazn']; const LAYA_CONFIDENCE_FLOOR = 0.65; const MERGE_THRESHOLD = 70; function tokens(value) { return String(value || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); } function strongFamily(value) { const text = tokens(value).join(' '); return STRONG_FAMILIES.find(family => text.includes(family)) || null; } function identityNumber(value, family = strongFamily(value)) { const list = tokens(value); let start = 0; if (family) { const familyTokens = family.split(' '); for (let index = 0; index <= list.length - familyTokens.length; index++) { if (familyTokens.every((token, offset) => list[index + offset] === token)) { start = index + familyTokens.length; break; } } } return list.slice(start).find(token => /^\d+$/.test(token)) || null; } function identity(value) { const list = tokens(value); const family = strongFamily(value); return { family, number: identityNumber(value, family), protectedTokens: new Set(list.filter(token => PROTECTED_TOKENS.has(token))) }; } function sameSet(left, right) { return left.size === right.size && [...left].every(value => right.has(value)); } function hardBlock(left, right) { const a = identity(left.fingerprint || left.name); const b = identity(right.fingerprint || right.name); if (!sameSet(a.protectedTokens, b.protectedTokens)) return 'protected_token_mismatch'; if (a.number && b.number && a.number !== b.number) return 'number_mismatch'; if (a.family && b.family && a.family !== b.family) return 'strong_family_mismatch'; return null; } function pairKey(left, right) { return [String(left.id), String(right.id)].sort().join('\0'); } function layaSignal(hint, leftIdentity, rightIdentity) { if (!hint || Number(hint.confidence) < LAYA_CONFIDENCE_FLOOR) return 0; if (hint.number && leftIdentity.number && hint.number !== leftIdentity.number) return 0; if (hint.number && rightIdentity.number && hint.number !== rightIdentity.number) return 0; if (hint.family && leftIdentity.family && hint.family !== leftIdentity.family) return 0; if (hint.family && rightIdentity.family && hint.family !== rightIdentity.family) return 0; return Number(hint.sameIdentityProbability) >= 0.5 ? Math.min(1, Math.max(0, Number(hint.confidence))) : 0; } function scoreCandidate(left, right, options = {}) { const mode = MODE_WEIGHTS[options.mode] ? options.mode : 'algo'; const weights = MODE_WEIGHTS[mode]; const blocker = hardBlock(left, right); if (blocker) return { matched: false, blocker, total: 0, signals: { algorithm: 0, structure: 0, metadata: 0, laya: 0 } }; const leftIdentity = identity(left.fingerprint || left.name); const rightIdentity = identity(right.fingerprint || right.name); const exact = left.fingerprint === right.fingerprint; const sameStructure = Boolean(leftIdentity.family && leftIdentity.family === rightIdentity.family && leftIdentity.number && leftIdentity.number === rightIdentity.number); const algorithm = exact ? 1 : (mode !== 'algo' && sameStructure ? 0.6 : 0); const structure = sameStructure ? 1 : 0; const metadata = Boolean( (left.category_id && left.category_id === right.category_id) || (left.tvg_id && left.tvg_id === right.tvg_id) ) ? 1 : 0; const laya = exact ? 0 : layaSignal(options.layaHint, leftIdentity, rightIdentity); const total = algorithm * weights.algorithm + structure * weights.structure + metadata * weights.metadata + laya * weights.laya; return { matched: total >= MERGE_THRESHOLD, blocker: null, total, signals: { algorithm, structure, metadata, laya } }; } module.exports = { LAYA_CONFIDENCE_FLOOR, MERGE_THRESHOLD, MODE_WEIGHTS, PROTECTED_TOKENS, hardBlock, identity, pairKey, scoreCandidate };