fix: missing referer header

This commit is contained in:
Kyush 2026-06-06 21:03:22 +09:00
commit 2f9fa8f76e
9 changed files with 107 additions and 32 deletions

3
.gitignore vendored
View file

@ -1,4 +1,5 @@
node_modules/
external/
dist/
nogit/
nogit/
ralph-loop.local.md

View file

@ -6,16 +6,32 @@ function isM3U8Url(url: string): boolean {
lower.includes('application/x-mpegurl') || lower.includes('application/vnd.apple.mpegurl');
}
export function monitorDOM(onDetected: (url: string) => void): void {
export function monitorDOM(onDetected: (url: string, headers?: Record<string, string>) => void): void {
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
const doc = target.document || document;
if (!doc) return;
let ctxReferer = '';
try {
ctxReferer = location.href;
} catch { /* cross-origin */ }
const getReferer = (src: string): string => {
try {
const srcObj = new URL(src);
if (srcObj.origin !== location.origin) {
return location.origin + '/';
}
} catch { /* ignore */ }
return ctxReferer;
};
const checkSource = (el: HTMLMediaElement) => {
const src = el.currentSrc || el.src || '';
if (src && isM3U8Url(src)) {
log.info(`DOM monitor: found m3u8 in <${el.tagName.toLowerCase()}> src: ${src}`);
onDetected(src);
const referer = getReferer(src);
onDetected(src, referer ? { Referer: referer } : undefined);
}
if (el.children) {
@ -24,7 +40,8 @@ export function monitorDOM(onDetected: (url: string) => void): void {
const sourceSrc = source.src || '';
if (sourceSrc && isM3U8Url(sourceSrc)) {
log.info(`DOM monitor: found m3u8 in <source> src: ${sourceSrc}`);
onDetected(sourceSrc);
const referer = getReferer(sourceSrc);
onDetected(sourceSrc, referer ? { Referer: referer } : undefined);
}
}
}
@ -49,7 +66,8 @@ export function monitorDOM(onDetected: (url: string) => void): void {
const src = el.getAttribute('src') || '';
if (isM3U8Url(src)) {
log.info(`DOM monitor: found m3u8 in <source> tag: ${src}`);
onDetected(src);
const referer = getReferer(src);
onDetected(src, referer ? { Referer: referer } : undefined);
}
}
}

View file

@ -7,7 +7,7 @@ function isM3U8Url(url: string): boolean {
return M3U8_PATTERNS.some(p => lower.includes(p));
}
export function interceptFetch(onDetected: (url: string) => void): void {
export function interceptFetch(onDetected: (url: string, headers?: Record<string, string>) => void): void {
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
const origFetch = target.fetch;
if (!origFetch) return;
@ -27,7 +27,14 @@ export function interceptFetch(onDetected: (url: string) => void): void {
if (response.status >= 200 && response.status < 300) {
const isM3U8Response = isM3U8Url(url) || contentType.toLowerCase().includes('mpegurl');
if (isM3U8Response) {
onDetected(url);
try {
let referer = location.href;
const urlObj = new URL(url);
if (urlObj.origin !== location.origin) {
referer = location.origin + '/';
}
onDetected(url, { Referer: referer });
} catch { /* cross-origin */ }
}
}
}

View file

@ -42,12 +42,21 @@ export function interceptXHR(onDetected: (url: string, headers?: Record<string,
if (status >= 200 && status < 300) {
const isM3U8Response = isM3U8Url(requestUrl) || contentType.toLowerCase().includes('mpegurl') || responseText.startsWith('#EXTM3U');
if (isM3U8Response) {
const capturedHeaders = Object.keys(headers).length ? headers : {};
if (!capturedHeaders.Referer) {
try {
capturedHeaders.Referer = location.href;
} catch { /* cross-origin */ }
const capturedHeaders: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (k.toLowerCase() !== 'referer') {
capturedHeaders[k] = v;
}
}
try {
const requestUrlObj = new URL(requestUrl);
const currentOrigin = location.origin;
if (requestUrlObj.origin !== currentOrigin) {
capturedHeaders.Referer = currentOrigin + '/';
} else {
capturedHeaders.Referer = location.href;
}
} catch { /* cross-origin */ }
onDetected(requestUrl, capturedHeaders);
}
}

View file

@ -1,4 +1,5 @@
import { log } from '../logger';
import { normalizeReferer } from '../utils/uri';
export interface DownloadProgress {
current: number;
@ -18,14 +19,17 @@ function gmFetchBuffer(url: string, referer?: string, origin?: string): Promise<
const fallbackOrigin = new URL(document.location.href).origin;
const effectiveReferer = referer || fallbackReferer;
const effectiveOrigin = origin || (referer ? new URL(referer).origin : fallbackOrigin);
const normalizedReferer = normalizeReferer(url, effectiveReferer);
log.info(`gmFetchBuffer request: url=${url} referer=${normalizedReferer} origin=${effectiveOrigin}`);
return new Promise((resolve, reject) => {
(GM_xmlhttpRequest as any)({
method: 'GET',
url: url,
responseType: 'arraybuffer',
referer: effectiveReferer,
headers: {
'Referer': normalizedReferer,
'Origin': effectiveOrigin,
},
onload: (response: any) => {
@ -36,10 +40,12 @@ function gmFetchBuffer(url: string, referer?: string, origin?: string): Promise<
bytes: buffer.byteLength,
});
} else {
log.error(`gmFetchBuffer HTTP error: status=${response.status} url=${url} referer=${effectiveReferer} origin=${effectiveOrigin} headers=${JSON.stringify(response.responseHeaders)}`);
reject(new Error(`Segment fetch failed: HTTP ${response.status} for ${url}`));
}
},
onerror: (error: any) => {
log.error(`gmFetchBuffer network error: url=${url} referer=${effectiveReferer} origin=${effectiveOrigin} error=${error.error} status=${error.status} readyState=${error.readyState} responseHeaders=${JSON.stringify(error.responseHeaders || {})}`);
reject(new Error(`Segment fetch error: ${error.error} for ${url}`));
},
});

View file

@ -1,4 +1,5 @@
import { log } from './logger';
import { normalizeReferer } from './utils/uri';
import { interceptXHR } from './detection/xhr-intercept';
import { interceptFetch } from './detection/fetch-intercept';
import { monitorDOM } from './detection/dom-monitor';
@ -26,13 +27,15 @@ function isTopFrame(): boolean {
function notifyTopFrame(url: string, headers?: Record<string, string>): void {
if (!isTopFrame()) {
try {
const referer = headers?.Referer || location.href;
const normalizedReferer = normalizeReferer(url, referer);
window.top.postMessage({
__m3u8dl: true,
url,
referer: headers?.Referer || location.href,
referer: normalizedReferer,
origin: location.origin,
}, '*');
log.info(`notifyTopFrame: sent via postMessage: ${url}`);
log.info(`notifyTopFrame: sent via postMessage: ${url} referer=${normalizedReferer}`);
} catch (e) {
log.warn(`notifyTopFrame: postMessage failed: ${e}`);
}
@ -85,6 +88,7 @@ async function startDownload(url: string): Promise<void> {
downloadAborted = false;
const entry = detectedM3U8s.get(url);
log.info(`startDownload: ${url} entryHeaders=${JSON.stringify(entry?.headers)}`);
let parsed = entry?.parsed;
if (!parsed) {
@ -123,7 +127,7 @@ async function startDownload(url: string): Promise<void> {
}
const segmentUrls = parsed.segments.map(s => s.uri);
const referrer = entry?.headers?.Referer;
const referrer = entry?.headers?.Referer || document.location.href;
const requestOrigin = entry?.headers?.Origin;
resetProgress();
@ -159,7 +163,7 @@ async function startDownload(url: string): Promise<void> {
function onM3U8Detected(url: string, headers?: Record<string, string>): void {
if (detectedM3U8s.has(url)) return;
log.info(`onM3U8Detected: ${url}`);
log.info(`onM3U8Detected: ${url} headers=${JSON.stringify(headers)}`);
detectedM3U8s.set(url, { url, headers });
notifyTopFrame(url, headers);

View file

@ -1,23 +1,25 @@
import { log } from '../logger';
import { normalizeReferer } from '../utils/uri';
export async function fetchKey(keyUri: string, referer?: string, origin?: string): Promise<Uint8Array> {
log.info(`fetchKey: ${keyUri}`);
const fallbackReferer = document.location.href;
const fallbackOrigin = new URL(document.location.href).origin;
const effectiveReferer = referer || fallbackReferer;
const effectiveReferer = normalizeReferer(keyUri, referer || fallbackReferer);
const effectiveOrigin = origin || (referer ? new URL(referer).origin : fallbackOrigin);
log.info(`fetchKey request: url=${keyUri} referer=${effectiveReferer} origin=${effectiveOrigin}`);
return new Promise((resolve, reject) => {
(GM_xmlhttpRequest as any)({
method: 'GET',
url: keyUri,
responseType: 'arraybuffer',
referer: effectiveReferer,
headers: {
'Referer': effectiveReferer,
'Origin': effectiveOrigin,
},
onload: (response: any) => {
log.info(`fetchKey response: status=${response.status} readyState=${response.readyState} headers=${JSON.stringify(response.responseHeaders)}`);
if (response.status >= 200 && response.status < 300) {
const buffer = response.detail?.response || response.response;
const keyBytes = new Uint8Array(buffer);
@ -29,10 +31,12 @@ export async function fetchKey(keyUri: string, referer?: string, origin?: string
resolve(keyBytes);
} else {
log.error(`fetchKey HTTP error: status=${response.status} url=${keyUri} responseHeaders=${JSON.stringify(response.responseHeaders)}`);
reject(new Error(`Key fetch failed: HTTP ${response.status}`));
}
},
onerror: (error: any) => {
log.error(`fetchKey network error: url=${keyUri} error=${error.error} status=${error.status} readyState=${error.readyState} responseHeaders=${JSON.stringify(error.responseHeaders || {})}`);
reject(new Error(`Key fetch error: ${error.error}`));
},
});

View file

@ -1,6 +1,6 @@
import * as m3u8Parser from 'm3u8-parser';
import { log } from '../logger';
import { resolveUri } from '../utils/uri';
import { resolveUri, normalizeReferer } from '../utils/uri';
import { fetchKey } from './key-fetch';
export interface SegmentInfo {
@ -34,30 +34,42 @@ function gmFetchText(url: string, referer?: string, origin?: string): Promise<st
const effectiveReferer = referer || fallbackReferer;
const effectiveOrigin = origin || (referer ? new URL(referer).origin : fallbackOrigin);
const normalizedReferer = normalizeReferer(url, effectiveReferer);
log.info(`gmFetchText request: url=${url} referer=${normalizedReferer} origin=${effectiveOrigin}`);
return new Promise((resolve, reject) => {
(GM_xmlhttpRequest as any)({
method: 'GET',
url: url,
responseType: 'text',
referer: effectiveReferer,
headers: {
'Referer': normalizedReferer,
'Origin': effectiveOrigin,
},
onload: (response: any) => {
log.info(`gmFetchText response: status=${response.status} readyState=${response.readyState} headers=${JSON.stringify(response.responseHeaders)}`);
if (response.status >= 200 && response.status < 300) {
resolve(response.responseText || response.response || '');
} else {
log.error(`gmFetchText HTTP error: status=${response.status} url=${url} responseHeaders=${JSON.stringify(response.responseHeaders)} bodyLen=${(response.responseText || '').length} bodyPreview=${(response.responseText || '').substring(0, 200)}`);
reject(new Error(`Failed to fetch ${url}: HTTP ${response.status}`));
}
},
onerror: (error: any) => {
log.error(`gmFetchText network error: url=${url} error=${error.error} status=${error.status} readyState=${error.readyState} responseHeaders=${JSON.stringify(error.responseHeaders || {})}`);
reject(new Error(`Failed to fetch ${url}: ${error.error}`));
},
});
});
}
export async function parseM3U8(url: string, referer?: string, origin?: string): Promise<ParsedPlaylist> {
export async function parseM3U8(
url: string,
referer?: string,
origin?: string,
fetchKeys = true
): Promise<ParsedPlaylist> {
log.info(`parseM3U8: ${url}`);
const content = await gmFetchText(url, referer, origin);
@ -108,13 +120,16 @@ export async function parseM3U8(url: string, referer?: string, origin?: string):
log.warn(`parseM3U8: unsupported encryption method ${method}`);
}
let keyBytes = keyCache.get(keyUri);
if (!keyBytes) {
try {
keyBytes = await fetchKey(keyUri, referer, origin);
keyCache.set(keyUri, keyBytes);
} catch (e) {
log.error(`parseM3U8: failed to fetch key ${keyUri}: ${e}`);
let keyBytes: Uint8Array | null = null;
if (fetchKeys) {
keyBytes = keyCache.get(keyUri);
if (!keyBytes) {
try {
keyBytes = await fetchKey(keyUri, referer, origin);
keyCache.set(keyUri, keyBytes);
} catch (e) {
log.error(`parseM3U8: failed to fetch key ${keyUri}: ${e}`);
}
}
}
@ -140,7 +155,7 @@ export async function parseM3U8(url: string, referer?: string, origin?: string):
resolvedSegments.push({ uri, key: keyInfo });
}
log.info(`parseM3U8: ${resolvedSegments.length} segments, encrypted=${resolvedSegments.some(s => s.key !== null)}`);
log.info(`parseM3U8: ${resolvedSegments.length} segments, encrypted=${resolvedSegments.some(s => s.key !== null)}, fetchKeys=${fetchKeys}`);
return {
segments: resolvedSegments,
isMaster: false,

View file

@ -1,3 +1,14 @@
export function normalizeReferer(targetUrl: string, referer: string): string {
try {
const targetOrigin = new URL(targetUrl).origin;
const refererOrigin = new URL(referer).origin;
if (targetOrigin !== refererOrigin) {
return refererOrigin + '/';
}
} catch { /* ignore */ }
return referer;
}
export function resolveUri(root: string, rel: string): string {
if (rel.startsWith('http://') || rel.startsWith('https://') || rel.startsWith('//')) {
return rel;