Files
KVideo/lib/utils/m3u8-ad-scoring.ts
kuekhaoyang cdab3b1c8a fix: harden M3U8 ad filtering
Reject unsafe dynamic filter execution, reduce heuristic false positives, add regression coverage, bump the app to 4.9.17, and update Next.js to the latest stable security patch.
2026-07-29 22:13:54 +08:00

101 lines
3.2 KiB
TypeScript

import {
isSmallIntegerDurationBlock,
isSmallNtscLikeBlock,
} from './m3u8-duration-grid';
import { extractFilename, extractSegmentLocation } from './m3u8-ad-pattern';
import type { Block, MainPattern } from './m3u8-ad-types';
export const AD_PATH_KEYWORDS = [
'advert', 'preroll', 'midroll', 'postroll',
'dai', 'vast', 'ima', 'adjump', 'commercial', 'sponsor',
];
function scoreKeywords(block: Block, extraKeywords: string[]): number {
const keywords = [
...AD_PATH_KEYWORDS,
...extraKeywords.filter(keyword => keyword.length > 2),
];
return block.segments.reduce((score, segment) => {
const url = segment.url.toLowerCase();
return keywords.some(keyword => url.includes(keyword.toLowerCase()))
? score + 2.5
: score;
}, 0);
}
function scoreFilenameMismatch(block: Block, mainPattern: MainPattern): number {
if (!mainPattern.filenameRegex || block.segments.length === 0) return 0;
const allMismatch = block.segments.every(segment => (
!mainPattern.filenameRegex?.test(extractFilename(segment.url))
));
return allMismatch ? 1.5 : 0;
}
function scoreLocationMismatch(block: Block, mainPattern: MainPattern): number {
if ((!mainPattern.pathPrefix && !mainPattern.origin) || block.segments.length === 0) {
return 0;
}
const locations = block.segments.map(segment => extractSegmentLocation(segment.url));
const allPathsMismatch = locations.every(location => (
location.pathPrefix !== mainPattern.pathPrefix
));
if (allPathsMismatch) return 5;
const allOriginsMismatch = locations.every(location => (
Boolean(mainPattern.origin && location.origin) && location.origin !== mainPattern.origin
));
return allOriginsMismatch ? 3.5 : 0;
}
function scoreDurationMismatch(block: Block, mainPattern: MainPattern): number {
if (mainPattern.avgDuration <= 0 || block.segments.length === 0 || block.segments.length > 6) {
return 0;
}
const blockAverage = block.segments.reduce((sum, segment) => (
sum + segment.duration
), 0) / block.segments.length;
const durationRatio = blockAverage / mainPattern.avgDuration;
return durationRatio < 0.6 || durationRatio > 1.8 ? 1.5 : 0;
}
function scoreDurationGrid(block: Block, mainPattern: MainPattern): number {
if (!mainPattern.usesNtscLikeGrid && isSmallNtscLikeBlock(block.segments)) {
return 3.5;
}
if (mainPattern.usesFilm24LikeGrid && isSmallIntegerDurationBlock(block.segments)) {
return 3.5;
}
return 0;
}
export function scoreBlock(
block: Block,
mainPattern: MainPattern,
extraKeywords: string[] = [],
isDuplicateSignature: boolean = false,
): number {
if (block.hasCueTag || isDuplicateSignature) return 10;
return scoreKeywords(block, extraKeywords) +
scoreFilenameMismatch(block, mainPattern) +
scoreLocationMismatch(block, mainPattern) +
scoreDurationMismatch(block, mainPattern) +
scoreDurationGrid(block, mainPattern);
}
export const THRESHOLDS = {
HIGH: 5,
LOW: 3,
};
export function shouldFilterBlock(
score: number,
threshold: number = THRESHOLDS.HIGH,
): boolean {
return score >= threshold;
}