feat: Enhance M3U8 ad detection with proxy URL unwrapping and segment-level analysis, and implement proxy fallback for HLS playlist fetching.

This commit is contained in:
kuekhaoyang
2026-02-16 10:31:09 +08:00
parent 9aafe20505
commit bb7bc3e75f
5 changed files with 265 additions and 260 deletions
+22 -3
View File
@@ -103,12 +103,30 @@ export function parseBlocks(lines: string[]): Block[] {
return blocks;
}
/**
* Unwrap proxied URL to get original URL
*/
function unwrapProxyUrl(url: string): string {
if (url.includes('/api/proxy?url=')) {
try {
const match = url.match(/[?&]url=([^&]+)/);
if (match && match[1]) {
return decodeURIComponent(match[1]);
}
} catch {
return url;
}
}
return url;
}
/**
* Extract filename from URL (handles both relative and absolute URLs)
*/
function extractFilename(url: string): string {
try {
const path = url.includes('://') ? new URL(url).pathname : url;
const unwrappedUrl = unwrapProxyUrl(url);
const path = unwrappedUrl.includes('://') ? new URL(unwrappedUrl).pathname : unwrappedUrl;
const parts = path.split('/');
return parts[parts.length - 1] || '';
} catch {
@@ -143,7 +161,8 @@ function findCommonPrefix(strings: string[]): string {
*/
function extractPathPrefix(url: string): string {
try {
const path = url.includes('://') ? new URL(url).pathname : url;
const unwrappedUrl = unwrapProxyUrl(url);
const path = unwrappedUrl.includes('://') ? new URL(unwrappedUrl).pathname : unwrappedUrl;
const lastSlash = path.lastIndexOf('/');
return lastSlash >= 0 ? path.substring(0, lastSlash + 1) : '';
} catch {
@@ -234,7 +253,7 @@ export function scoreBlock(block: Block, mainPattern: MainPattern, extraKeywords
// **KEY FEATURE**: Check path prefix mismatch (e.g., different date/folder/bitrate)
// This is the most reliable indicator for ads that come from different CDN paths
if (mainPattern.pathPrefix && block.segments.length > 0) {
if (mainPattern.pathPrefix !== undefined && block.segments.length > 0) {
const pathMismatchCount = block.segments.filter(s => {
const segmentPathPrefix = extractPathPrefix(s.url);
return segmentPathPrefix !== mainPattern.pathPrefix;
+33 -3
View File
@@ -25,10 +25,21 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod
// Use keywords passed from AdKeywordsWrapper (already loaded from env/file)
const keywords = customKeywords;
const basePath = baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1);
// Unwrap baseUrl if it's a proxy URL to get correct basePath and origin
let effectiveBaseUrl = baseUrl;
if (baseUrl.includes('/api/proxy?url=')) {
try {
const urlMatch = baseUrl.match(/[?&]url=([^&]+)/);
if (urlMatch && urlMatch[1]) {
effectiveBaseUrl = decodeURIComponent(urlMatch[1]);
}
} catch (e) { /* ignore */ }
}
const basePath = effectiveBaseUrl.substring(0, effectiveBaseUrl.lastIndexOf('/') + 1);
let origin = '';
try {
origin = new URL(baseUrl).origin;
origin = new URL(effectiveBaseUrl).origin;
} catch (e) { /* ignore */ }
// 2. Global Scan: Check if any ad keywords exist in the content
@@ -42,18 +53,37 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod
if (!hasCueTag && (mode === 'heuristic' || mode === 'aggressive')) {
// No obvious ad signals - run heuristic analysis
const blocks = parseBlocks(lines);
if (blocks.length > 1) {
if (blocks.length > 0) {
const mainPattern = learnMainPattern(blocks);
for (const block of blocks) {
// Pass all keywords (including custom ones) to heuristic scorer
const score = scoreBlock(block, mainPattern, keywords);
const threshold = mode === 'aggressive' ? 3.0 : 5.0;
if (shouldFilterBlock(score, threshold)) {
// Mark all lines in this block for removal
for (const segment of block.segments) {
adLineIndices.add(segment.lineIndex);
adLineIndices.add(segment.lineIndex - 1); // EXTINF line
}
} else if (block.segments.length > 0) {
// Segment-level detection:
// Even if the whole block didn't trigger, check segments individually
// if it's a suspicious single-segment "block" (common for ads without discontinuity)
for (const segment of block.segments) {
const singleSegmentBlock = {
segments: [segment],
hasCueTag: false,
startLineIndex: segment.lineIndex - 1,
endLineIndex: segment.lineIndex
};
const segmentScore = scoreBlock(singleSegmentBlock, mainPattern, keywords);
// Higher threshold for individual segments to avoid false positives
if (segmentScore >= 4.0) {
adLineIndices.add(segment.lineIndex);
adLineIndices.add(segment.lineIndex - 1);
}
}
}
}
}