← Files DefuddleARCHIVED FILE

skills/defuddle-web-content/references/source/src/extractors/bilibili.ts

14.8 KB · Oct 2, 2026 · 00:35 UTC

↓ Download file

import { BaseExtractor, ExtractorOptions } from './_base';
import { ExtractorResult } from '../types/extractors';
import { escapeHtml } from '../utils/dom';
import { buildTranscript, TranscriptSegment, TranscriptResult as BuiltTranscript } from '../utils/transcript';

const FETCH_TIMEOUT_MS = 4000;
const TRANSCRIPT_GROUP_GAP_SECONDS = 20;
const TRANSCRIPT_MAX_GROUP_SECONDS = 30;
const HAN_CHAR = /[\u4E00-\u9FFF]/;

type ViewApiPage = {
	cid: number;
	page: number;
	part?: string;
	duration?: number;
};

type ViewApiData = {
	bvid: string;
	aid: number;
	title?: string;
	desc?: string;
	pic?: string;
	pubdate?: number;
	owner?: { name?: string };
	pages?: ViewApiPage[];
};

type ViewApiResponse = {
	code: number;
	message?: string;
	data?: ViewApiData;
};

type PlayerSubtitleTrack = {
	lan: string;
	lan_doc?: string;
	subtitle_url: string;
	id?: number;
	is_ai_subtitle?: boolean;
};

type PlayerV2Response = {
	code: number;
	message?: string;
	data?: {
		subtitle?: {
			subtitles?: PlayerSubtitleTrack[];
		};
	};
};

type SubtitleLine = {
	from: number;
	to?: number;
	content: string;
};

type SubtitleJson = {
	body?: SubtitleLine[];
};

type TranscriptResult = BuiltTranscript & { languageCode?: string };

export class BilibiliExtractor extends BaseExtractor {
	private static transcriptCache = new Map<string, TranscriptResult | null>();
	private _bvid: string | undefined;

	constructor(document: Document, url: string, schemaOrgData?: any, options?: ExtractorOptions) {
		super(document, url, schemaOrgData, options);
	}

	canExtract(): boolean {
		return !!this.getBvid();
	}

	canExtractAsync(): boolean {
		return this.canExtract();
	}

	prefersAsync(): boolean {
		return true;
	}

	extract(): ExtractorResult {
		return this.buildResult();
	}

	async extractAsync(): Promise<ExtractorResult> {
		const bvid = this.getBvid();
		if (!bvid) return this.buildResult();

		const view = await this.fetchViewData(bvid);
		if (!view) return this.buildResult();

		const { aid, pages = [] } = view;
		const pageNumber = this.getPageNumber();
		const pageInfo = (pages.length > 0 ? (pages[pageNumber - 1] ?? pages[0]) : undefined);
		const cid = pageInfo?.cid;
		const preferredLang = this.normalizeLanguageCode(this.options.language);
		const cacheKey = (aid && cid) ? `${bvid}:${cid}:${preferredLang}` : '';

		let transcript: TranscriptResult | undefined;
		if (aid && cid) {
			if (cacheKey && BilibiliExtractor.transcriptCache.has(cacheKey)) {
				const cached = BilibiliExtractor.transcriptCache.get(cacheKey);
				transcript = cached ?? undefined;
			} else {
				transcript = await this.fetchTranscript(aid, cid, bvid);
				if (cacheKey) {
					BilibiliExtractor.transcriptCache.set(cacheKey, transcript ?? null);
					if (BilibiliExtractor.transcriptCache.size > 300) {
						const firstKey = BilibiliExtractor.transcriptCache.keys().next().value;
						if (firstKey) BilibiliExtractor.transcriptCache.delete(firstKey);
					}
				}
			}
		}

		return this.buildResult(transcript, view, pageInfo, pageNumber);
	}

	private getBvid(): string {
		if (this._bvid !== undefined) return this._bvid;
		try {
			const url = new URL(this.url);
			const match = url.pathname.match(/\/video\/(BV[0-9A-Za-z]+)\/?/);
			this._bvid = match?.[1] ?? '';
		} catch {
			this._bvid = '';
		}
		return this._bvid;
	}

	private getPageNumber(): number {
		try {
			const url = new URL(this.url);
			const p = parseInt(new URLSearchParams(url.search).get('p') || '1', 10);
			return Number.isFinite(p) && p > 0 ? p : 1;
		} catch {
			return 1;
		}
	}

	private formatDescription(desc: string): string {
		const safe = escapeHtml(desc).replace(/\n/g, '<br>');
		return safe ? `<p>${safe}</p>` : '';
	}

	private buildEmbedHtml(bvid: string, pageNumber: number): string {
		const src = `https://player.bilibili.com/player.html?bvid=${encodeURIComponent(bvid)}&page=${pageNumber}&high_quality=1&danmaku=0`;
		return `<iframe width="560" height="315" src="${src}" title="Bilibili video player" frameborder="0" allowfullscreen></iframe>`;
	}

	private buildResult(
		transcript?: TranscriptResult,
		view?: ViewApiData,
		pageInfo?: ViewApiPage,
		pageNumber?: number,
	): ExtractorResult {
		const bvid = this.getBvid();
		const title = view?.title || this.document.title || '';
		const author = view?.owner?.name || '';
		const desc = view?.desc || '';
		const description = desc.slice(0, 200).trim();
		const image = view?.pic || '';
		const published = view?.pubdate ? new Date(view.pubdate * 1000).toISOString() : '';

		let contentHtml = '';
		if (bvid) {
			contentHtml += this.buildEmbedHtml(bvid, pageNumber || this.getPageNumber());
		}
		if (desc) {
			contentHtml += this.formatDescription(desc);
		}
		if (transcript?.html) {
			contentHtml += transcript.html;
		}

		const variables: { [key: string]: string } = {
			title,
			author,
			site: 'Bilibili',
			image,
			published,
			description,
		};

		if (pageInfo?.part) {
			variables.part = pageInfo.part;
		}

		if (transcript?.text) {
			variables.transcript = transcript.text;
		}
		if (transcript?.languageCode) {
			variables.language = transcript.languageCode;
		}

		return {
			content: contentHtml,
			contentHtml,
			extractedContent: {
				videoId: bvid,
				...(pageInfo?.cid ? { cid: String(pageInfo.cid) } : {}),
			},
			variables,
		};
	}


	private normalizeLanguageCode(code?: string): string {
		return (code || '').trim().replace(/_/g, '-').toLocaleLowerCase();
	}

	private pickSubtitleTrack(tracks: PlayerSubtitleTrack[], preferredLang?: string): PlayerSubtitleTrack | undefined {
		if (tracks.length === 0) return undefined;

		const pref = this.normalizeLanguageCode(preferredLang);
		const prefBase = pref ? pref.split('-')[0] : '';

		const stableUrlKey = (u?: string): string => {
			const input = (u || '').trim();
			if (!input) return '';
			try {
				const normalized = input.startsWith('//') ? `https:${input}` : input;
				const parsed = new URL(normalized);
				return `${parsed.hostname.toLocaleLowerCase()}${parsed.pathname}`;
			} catch {
				return input.split('?')[0].split('#')[0];
			}
		};

		const normalizeDoc = (doc?: string): string => (doc || '').trim().toLocaleLowerCase();
		const isLikelyAiDoc = (doc?: string): boolean => {
			const d = normalizeDoc(doc);
			return d.includes('ai') || d.includes('auto') || d.includes('自动');
		};

		const langPriority = (code: string): number => {
			// Stable language preference when no explicit language provided.
			if (code === 'zh-cn' || code === 'zh-hans') return 0;
			if (code === 'zh') return 1;
			if (code.startsWith('zh-')) return 2;
			if (code === 'en' || code.startsWith('en-')) return 3;
			return 4;
		};

		const ranked = tracks
			.map((t, index) => {
				const code = this.normalizeLanguageCode(t.lan);
				let prefScore = 3;
				if (pref) {
					if (code === pref) prefScore = 0;
					else if (prefBase && code === prefBase) prefScore = 1;
					else if (prefBase && code.split('-')[0] === prefBase) prefScore = 2;
				}

				const aiScore = t.is_ai_subtitle || isLikelyAiDoc(t.lan_doc) ? 1 : 0;
				const lp = langPriority(code);
				const id = typeof t.id === 'number' ? t.id : Number.MAX_SAFE_INTEGER;
				const doc = normalizeDoc(t.lan_doc);
				const urlKey = stableUrlKey(t.subtitle_url);

				return { t, prefScore, aiScore, lp, id, doc, urlKey, index };
			})
			.sort((a, b) => {
				if (a.prefScore !== b.prefScore) return a.prefScore - b.prefScore;
				if (a.aiScore !== b.aiScore) return a.aiScore - b.aiScore;
				if (a.lp !== b.lp) return a.lp - b.lp;
				if (a.id !== b.id) return a.id - b.id;
				if (a.doc !== b.doc) return a.doc.localeCompare(b.doc);
				if (a.urlKey !== b.urlKey) return a.urlKey.localeCompare(b.urlKey);
				return a.index - b.index;
			});

		return ranked[0]?.t;
	}

	private async fetchViewData(bvid: string): Promise<ViewApiData | undefined> {
		try {
			const url = `https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`;
			const response = await this.fetch(url, {
				headers: {
					'Accept': 'application/json',
					'User-Agent': 'Mozilla/5.0 (compatible; Defuddle/1.0)',
				},
				credentials: 'include' as RequestCredentials,
				signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
			});
			if (!response.ok) return undefined;
			const json = await response.json() as ViewApiResponse;
			if (json?.code !== 0) return undefined;
			return json.data;
		} catch {
			return undefined;
		}
	}

	private parseSubtitleTracks(json: any): PlayerSubtitleTrack[] {
		const candidates = [
			json?.data?.subtitle?.subtitles,
			json?.data?.subtitle?.list,
			json?.data?.subtitle?.tracks,
		];
		for (const cand of candidates) {
			if (!Array.isArray(cand)) continue;
			return cand
				.map((t: any) => ({
					lan: String(t?.lan ?? t?.lang ?? t?.language ?? ''),
					lan_doc: t?.lan_doc ? String(t.lan_doc) : undefined,
					subtitle_url: String(t?.subtitle_url ?? t?.subtitleUrl ?? t?.url ?? ''),
					id: typeof t?.id === 'number' ? t.id : (typeof t?.subtitle_id === 'number' ? t.subtitle_id : undefined),
					is_ai_subtitle: typeof t?.is_ai_subtitle === 'boolean'
						? t.is_ai_subtitle
						: (typeof t?.ai_type === 'number' ? t.ai_type > 0 : undefined),
				}))
				.filter((t: PlayerSubtitleTrack) => !!t.lan && !!t.subtitle_url);
		}
		return [];
	}

	private async fetchPlayerV2(url: string): Promise<{ tracks: PlayerSubtitleTrack[]; code?: number; message?: string }> {
		const response = await this.fetch(url, {
			headers: {
				'Accept': 'application/json',
				'User-Agent': 'Mozilla/5.0 (compatible; Defuddle/1.0)',
			},
			credentials: 'include' as RequestCredentials,
			signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
		});
		if (!response.ok) {
			return { tracks: [], code: response.status, message: `http_${response.status}` };
		}
		const json = await response.json() as PlayerV2Response;
		const code = typeof (json as any)?.code === 'number' ? (json as any).code : undefined;
		const message = typeof (json as any)?.message === 'string' ? (json as any).message : undefined;
		if (code !== 0) {
			return { tracks: [], code, message };
		}
		return { tracks: this.parseSubtitleTracks(json), code, message };
	}

	private async fetchTranscript(aid: number, cid: number, bvid: string): Promise<TranscriptResult | undefined> {
		try {
			// Deterministic API order to minimize drift across repeated extraction:
			// 1) wbi/v2 (most complete), 2) v2 with bvid+cid, 3) v2 with aid+cid.
			const urlWbi = `https://api.bilibili.com/x/player/wbi/v2?bvid=${encodeURIComponent(String(bvid))}&aid=${encodeURIComponent(String(aid))}&cid=${encodeURIComponent(String(cid))}`;
			const urlBvid = `https://api.bilibili.com/x/player/v2?bvid=${encodeURIComponent(String(bvid))}&cid=${encodeURIComponent(String(cid))}`;
			const urlAid = `https://api.bilibili.com/x/player/v2?aid=${encodeURIComponent(String(aid))}&cid=${encodeURIComponent(String(cid))}`;

			let tracks: PlayerSubtitleTrack[] = [];
			const first = await this.fetchPlayerV2(urlWbi);
			tracks = first.tracks;

			if (tracks.length === 0) {
				const second = await this.fetchPlayerV2(urlBvid);
				tracks = second.tracks;
				if (tracks.length === 0) {
					const third = await this.fetchPlayerV2(urlAid);
					tracks = third.tracks;
				}
			}

			if (!Array.isArray(tracks) || tracks.length === 0) {
				return undefined;
			}

			const picked = this.pickSubtitleTrack(tracks, this.options.language);
			if (!picked?.subtitle_url) {
				return undefined;
			}

			const subtitleUrl = this.normalizeSubtitleUrl(picked.subtitle_url);
			if (!subtitleUrl) {
				return undefined;
			}
			if (!this.isAllowedSubtitleHost(subtitleUrl)) {
				return undefined;
			}

			const subtitleResponse = await this.fetch(subtitleUrl.toString(), {
				headers: {
					'Accept': 'application/json',
					'User-Agent': 'Mozilla/5.0 (compatible; Defuddle/1.0)',
				},
				signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
			});
			if (!subtitleResponse.ok) {
				return undefined;
			}
			const subtitleJson = await subtitleResponse.json() as SubtitleJson;
			const transcript = this.parseSubtitleJson(subtitleJson);
			if (!transcript) {
				return undefined;
			}
			return { ...transcript, languageCode: picked.lan };
		} catch {
			return undefined;
		}
	}

	private normalizeSubtitleUrl(url: string): URL | null {
		try {
			const trimmed = (url || '').trim();
			if (!trimmed) return null;
			// bilibili often returns protocol-relative URLs: //i0.hdslb.com/...
			const normalized = trimmed.startsWith('//') ? `https:${trimmed}` : trimmed;
			const parsed = new URL(normalized);
			if (parsed.protocol !== 'https:') return null;
			return parsed;
		} catch {
			return null;
		}
	}

	private isAllowedSubtitleHost(url: URL): boolean {
		const host = url.hostname.toLocaleLowerCase();
		return host.endsWith('.hdslb.com') || host.endsWith('.bilibili.com');
	}

	private parseSubtitleJson(json: SubtitleJson): BuiltTranscript | undefined {
		const lines = Array.isArray(json?.body) ? json.body : [];
		const cleaned = lines
			.map(l => ({
				start: typeof l.from === 'number' ? l.from : NaN,
				end: typeof l.to === 'number' ? l.to : (typeof l.from === 'number' ? l.from : NaN),
				text: (l.content || '').trim(),
			}))
			.filter(l => Number.isFinite(l.start) && l.text.length > 0)
			.sort((a, b) => a.start - b.start);

		if (cleaned.length === 0) return undefined;

		const segments = this.groupSubtitleLines(cleaned);
		const { html, text } = buildTranscript('bilibili', segments);
		return { html, text };
	}

	private groupSubtitleLines(lines: Array<{ start: number; end: number; text: string }>): TranscriptSegment[] {
		const groups: TranscriptSegment[] = [];
		let currentStart = lines[0].start;
		let currentEnd = lines[0].end;
		let currentText = lines[0].text;

		const flush = () => {
			const normalized = currentText.replace(/\s+/g, ' ').trim();
			if (!normalized) return;
			groups.push({
				start: Math.max(0, Math.floor(currentStart)),
				text: normalized,
				speakerChange: groups.length > 0,
			});
		};

		for (let i = 1; i < lines.length; i++) {
			const line = lines[i];
			const gap = line.start - currentEnd;
			const span = Math.max(line.end, line.start) - currentStart;
			const shouldSplit = gap > TRANSCRIPT_GROUP_GAP_SECONDS || span > TRANSCRIPT_MAX_GROUP_SECONDS;

			if (shouldSplit) {
				flush();
				currentStart = line.start;
				currentEnd = line.end;
				currentText = line.text;
				continue;
			}

			currentText = this.concatTranscriptText(currentText, line.text);
			currentEnd = Math.max(currentEnd, line.end);
		}

		flush();
		return groups;
	}

	private concatTranscriptText(prev: string, next: string): string {
		const a = (prev || '').trimEnd();
		const b = (next || '').trimStart();
		if (!a) return b;
		if (!b) return a;

		const aLast = a[a.length - 1];
		const bFirst = b[0];
		const aHan = HAN_CHAR.test(aLast);
		const bHan = HAN_CHAR.test(bFirst);
		if (aHan && bHan) return a + b;

		const aAlnum = /[A-Za-z0-9]$/.test(a);
		const bAlnum = /^[A-Za-z0-9]/.test(b);
		if (aAlnum && bAlnum) return `${a} ${b}`;

		return `${a} ${b}`;
	}
}

SHA-256: 9339ae992aa55a2355b7c89a58f4a6ef24f6944ca8122fd8802ff0e6f860579a