youtube embeds

This commit is contained in:
Moon.eth
2026-07-29 11:53:15 +09:00
parent 2f0c8821f3
commit cd77720a4f
7 changed files with 274 additions and 1 deletions
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { extractYouTubeVideoIds, youtubeVideoId } from './youtube'
describe('youtubeVideoId', () => {
it.each([
['https://www.youtube.com/watch?v=pyNCigSulNs', 'pyNCigSulNs'],
['https://youtu.be/pyNCigSulNs?t=42', 'pyNCigSulNs'],
['https://m.youtube.com/shorts/pyNCigSulNs', 'pyNCigSulNs'],
['https://www.youtube.com/live/pyNCigSulNs', 'pyNCigSulNs'],
['https://www.youtube-nocookie.com/embed/pyNCigSulNs', 'pyNCigSulNs'],
])('recognizes %s', (url, expected) => {
expect(youtubeVideoId(url)).toBe(expected)
})
it('rejects lookalike hosts and malformed IDs', () => {
expect(youtubeVideoId('https://youtube.com.example.test/watch?v=pyNCigSulNs')).toBeNull()
expect(youtubeVideoId('https://www.youtube.com/watch?v=too-short')).toBeNull()
expect(youtubeVideoId('https://youtu.be/%invalid-id')).toBeNull()
expect(youtubeVideoId('javascript:alert(1)')).toBeNull()
})
})
describe('extractYouTubeVideoIds', () => {
it('finds and de-duplicates linked and plain-text YouTube URLs', () => {
const html = `
<p>
<a href="https://www.youtube.com/watch?v=pyNCigSulNs&amp;t=12">first</a>
https://youtu.be/pyNCigSulNs
<a href="https://youtu.be/dQw4w9WgXcQ">second</a>
</p>
`
expect(extractYouTubeVideoIds(html)).toEqual(['pyNCigSulNs', 'dQw4w9WgXcQ'])
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* YouTube links in status HTML.
*
* Mastodon and Pleroma normally linkify URLs before returning a status, but
* scanning visible text as well keeps this useful with older or unusual
* servers. Only a validated eleven-character video ID leaves this module.
*/
const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/
const YOUTUBE_HOSTS = new Set(['youtube.com', 'm.youtube.com', 'music.youtube.com'])
const EMBED_HOSTS = new Set(['youtube-nocookie.com'])
function decodeAttribute(value: string): string {
return value
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#(?:39|x27);/gi, "'")
}
function validVideoId(value: string | null | undefined): string | null {
if (!value) return null
try {
const decoded = decodeURIComponent(value)
return VIDEO_ID_PATTERN.test(decoded) ? decoded : null
} catch {
return null
}
}
export function youtubeVideoId(value: string): string | null {
let url: URL
try {
url = new URL(decodeAttribute(value).replace(/[),.;!?]+$/, ''))
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
const host = url.hostname.toLowerCase().replace(/^www\./, '')
if (host === 'youtu.be') return validVideoId(url.pathname.split('/')[1])
const segments = url.pathname.split('/').filter(Boolean)
if (YOUTUBE_HOSTS.has(host)) {
if (segments[0] === 'watch') return validVideoId(url.searchParams.get('v'))
if (['embed', 'live', 'shorts'].includes(segments[0])) return validVideoId(segments[1])
}
if (EMBED_HOSTS.has(host) && segments[0] === 'embed') {
return validVideoId(segments[1])
}
return null
}
export function extractYouTubeVideoIds(html: string | null | undefined): string[] {
if (!html) return []
const candidates: string[] = []
const hrefPattern = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi
const urlPattern =
/https?:\/\/(?:(?:www|m|music)\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)\/[^\s<>"']+/gi
for (const match of html.matchAll(hrefPattern)) {
candidates.push(match[1] ?? match[2] ?? match[3])
}
candidates.push(...html.match(urlPattern) ?? [])
const ids: string[] = []
const seen = new Set<string>()
for (const candidate of candidates) {
const id = youtubeVideoId(candidate)
if (id && !seen.has(id)) {
seen.add(id)
ids.push(id)
}
}
return ids
}
export function youtubeWatchUrl(videoId: string): string {
return `https://www.youtube.com/watch?v=${videoId}`
}
export function youtubeEmbedUrl(videoId: string): string {
return `https://www.youtube-nocookie.com/embed/${videoId}`
}