Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Fetch any URL via Chrome CDP and convert the rendered page to clean markdown with YouTube transcript support.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/lib/adapters/youtube/utils.ts
1export interface YouTubeTranscriptSegment {2start: number;3end: number;4text: string;5}67export interface YouTubeChapter {8title: string;9time: number;10}1112interface RenderYouTubeTranscriptMarkdownInput {13description?: string;14segments: YouTubeTranscriptSegment[];15chapters: YouTubeChapter[];16}1718const DESCRIPTION_CHAPTER_RE = /^((?:\d{1,2}:)?\d{1,2}:\d{2})(?:\s+[-|:]\s+|\s+)(.+)$/;19const YOUTUBE_THUMBNAIL_VARIANTS = [20"maxresdefault.jpg",21"sddefault.jpg",22"hqdefault.jpg",23"mqdefault.jpg",24"default.jpg",25];2627export function isYouTubeHost(hostname: string): boolean {28return [29"youtube.com",30"www.youtube.com",31"m.youtube.com",32"youtu.be",33].includes(hostname);34}3536export function parseYouTubeVideoId(url: URL): string | null {37if (url.hostname === "youtu.be") {38return url.pathname.split("/").filter(Boolean)[0] ?? null;39}4041if (url.pathname === "/watch") {42return url.searchParams.get("v");43}4445const shortsMatch = url.pathname.match(/^\/shorts\/([^/?#]+)/);46if (shortsMatch) {47return shortsMatch[1];48}4950const liveMatch = url.pathname.match(/^\/live\/([^/?#]+)/);51if (liveMatch) {52return liveMatch[1];53}5455return null;56}5758function parseTimestampValue(raw: string): number | null {59const parts = raw60.split(":")61.map((part) => Number.parseInt(part, 10))62.filter((part) => Number.isFinite(part));6364if (parts.length < 2 || parts.length > 3) {65return null;66}6768if (parts.some((part) => part < 0)) {69return null;70}7172if (parts.length === 2) {73const [minutes, seconds] = parts;74return minutes * 60 + seconds;75}7677const [hours, minutes, seconds] = parts;78return hours * 3600 + minutes * 60 + seconds;79}8081export function formatTimestamp(totalSeconds: number): string {82const rounded = Math.max(0, Math.floor(totalSeconds));83const hours = Math.floor(rounded / 3600);84const minutes = Math.floor((rounded % 3600) / 60);85const seconds = rounded % 60;8687if (hours > 0) {88return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;89}90return `${minutes}:${String(seconds).padStart(2, "0")}`;91}9293export function formatTimestampRange(start: number, end: number): string {94const safeStart = Math.max(0, start);95const safeEnd = Math.max(safeStart, end);96return `[${formatTimestamp(safeStart)} -> ${formatTimestamp(safeEnd)}]`;97}9899export function normalizeYouTubeChapters(chapters: YouTubeChapter[]): YouTubeChapter[] {100const seenTimes = new Set<number>();101102return chapters103.map((chapter) => ({104title: chapter.title.trim(),105time: Math.max(0, Math.floor(chapter.time)),106}))107.filter((chapter) => chapter.title)108.sort((left, right) => left.time - right.time)109.filter((chapter) => {110if (seenTimes.has(chapter.time)) {111return false;112}113seenTimes.add(chapter.time);114return true;115});116}117118export function parseYouTubeDescriptionChapters(description?: string | null): YouTubeChapter[] {119if (!description) {120return [];121}122123const chapters: YouTubeChapter[] = [];124const seen = new Set<string>();125126for (const rawLine of description.replace(/\r\n/g, "\n").split("\n")) {127const line = rawLine.trim();128if (!line) {129continue;130}131132const match = line.match(DESCRIPTION_CHAPTER_RE);133if (!match) {134continue;135}136137const time = parseTimestampValue(match[1]);138const title = match[2]?.trim();139if (time === null || !title) {140continue;141}142143const key = `${time}:${title.toLowerCase()}`;144if (seen.has(key)) {145continue;146}147148seen.add(key);149chapters.push({ title, time });150}151152const normalized = normalizeYouTubeChapters(chapters);153if (normalized.length >= 2) {154return normalized;155}156157if (normalized.length === 1 && normalized[0]?.time === 0) {158return normalized;159}160161return [];162}163164function renderDescriptionMarkdown(description: string): string {165return description166.replace(/\r\n/g, "\n")167.trim()168.split(/\n{2,}/)169.map((block) => block.split("\n").map((line) => line.trimEnd()).join(" \n"))170.join("\n\n")171.trim();172}173174function renderSegmentLine(segment: YouTubeTranscriptSegment): string {175return `${formatTimestampRange(segment.start, segment.end)} ${segment.text}`;176}177178export function renderYouTubeTranscriptMarkdown({179description,180segments,181chapters,182}: RenderYouTubeTranscriptMarkdownInput): string {183if (segments.length === 0) {184return "";185}186187const parts: string[] = [];188const normalizedDescription = description?.trim();189const transcriptEnd = segments.reduce((maxEnd, segment) => Math.max(maxEnd, segment.end, segment.start), 0);190const normalizedChapters = normalizeYouTubeChapters(chapters).filter(191(chapter) => transcriptEnd <= 0 || chapter.time < transcriptEnd,192);193194if (normalizedDescription) {195parts.push("## Description");196parts.push(renderDescriptionMarkdown(normalizedDescription));197}198199if (normalizedChapters.length > 0) {200parts.push("## Chapters");201202for (let index = 0; index < normalizedChapters.length; index += 1) {203const chapter = normalizedChapters[index];204const nextChapter = normalizedChapters[index + 1];205const chapterEnd = nextChapter ? nextChapter.time : transcriptEnd;206const chapterSegments = segments.filter(207(segment) => segment.start >= chapter.time && segment.start < chapterEnd,208);209210parts.push(`### ${chapter.title} ${formatTimestampRange(chapter.time, chapterEnd)}`);211212if (chapterSegments.length > 0) {213parts.push(chapterSegments.map(renderSegmentLine).join("\n"));214}215}216} else {217parts.push("## Transcript");218parts.push(segments.map(renderSegmentLine).join("\n"));219}220221return parts.filter(Boolean).join("\n\n").trim();222}223224function normalizeThumbnailKey(url: string): string {225try {226const parsed = new URL(url);227return `${parsed.origin}${parsed.pathname}`;228} catch {229return url;230}231}232233export function buildYouTubeThumbnailCandidates(videoId: string, listedUrls: string[]): string[] {234const candidates = [235...YOUTUBE_THUMBNAIL_VARIANTS.map((variant) => `https://i.ytimg.com/vi/${videoId}/${variant}`),236...listedUrls,237];238239const seen = new Set<string>();240return candidates.filter((candidate) => {241if (!candidate) {242return false;243}244245const key = normalizeThumbnailKey(candidate);246if (seen.has(key)) {247return false;248}249250seen.add(key);251return true;252});253}254