mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
youtube embeds
This commit is contained in:
@@ -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&t=12">first</a>
|
||||
https://youtu.be/pyNCigSulNs
|
||||
<a href="https://youtu.be/dQw4w9WgXcQ">second</a>
|
||||
</p>
|
||||
`
|
||||
|
||||
expect(extractYouTubeVideoIds(html)).toEqual(['pyNCigSulNs', 'dQw4w9WgXcQ'])
|
||||
})
|
||||
})
|
||||
@@ -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(/&/gi, '&')
|
||||
.replace(/"/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}`
|
||||
}
|
||||
Reference in New Issue
Block a user