feat(web): parse YouTube video id from pasted URL forms
Pure parser for watch?v=, youtu.be/, shorts/, embed/, and bare ids; rejects non-YouTube hosts and malformed input. Foundation for paste-a-URL summarize (Feature 2). No fetch, no gate interaction — parsing only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// youtubeVideoID matches a canonical YouTube video id: exactly 11 URL-safe chars.
|
||||||
|
var youtubeVideoID = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`)
|
||||||
|
|
||||||
|
// parseYouTubeVideoID extracts the 11-character video id from a pasted YouTube
|
||||||
|
// URL (watch?v=, youtu.be/, shorts/, embed/) or a bare id. It rejects non-YouTube
|
||||||
|
// hosts and anything that doesn't yield a valid id, so the paste flow never tries
|
||||||
|
// to fetch a video that can't exist (Feature 2).
|
||||||
|
func parseYouTubeVideoID(raw string) (string, error) {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
if s == "" {
|
||||||
|
return "", fmt.Errorf("empty input")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare id (no URL) — accept directly.
|
||||||
|
if youtubeVideoID.MatchString(s) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept scheme-less URLs (youtube.com/watch?v=...) by giving url.Parse a host.
|
||||||
|
if !strings.Contains(s, "://") {
|
||||||
|
s = "https://" + s
|
||||||
|
}
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("not a URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.ToLower(u.Hostname())
|
||||||
|
isYouTube := host == "youtu.be" || host == "youtube.com" || strings.HasSuffix(host, ".youtube.com")
|
||||||
|
if !isYouTube {
|
||||||
|
return "", fmt.Errorf("not a YouTube URL: %q", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
switch {
|
||||||
|
case host == "youtu.be":
|
||||||
|
// youtu.be/<id>
|
||||||
|
id = strings.Trim(u.Path, "/")
|
||||||
|
case u.Path == "/watch":
|
||||||
|
id = u.Query().Get("v")
|
||||||
|
default:
|
||||||
|
// /shorts/<id>, /embed/<id>
|
||||||
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||||
|
if len(parts) == 2 && (parts[0] == "shorts" || parts[0] == "embed") {
|
||||||
|
id = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !youtubeVideoID.MatchString(id) {
|
||||||
|
return "", fmt.Errorf("no YouTube video id in %q", raw)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseYouTubeVideoID(t *testing.T) {
|
||||||
|
const id = "dQw4w9WgXcQ"
|
||||||
|
ok := []struct {
|
||||||
|
name, in string
|
||||||
|
}{
|
||||||
|
{"watch", "https://www.youtube.com/watch?v=" + id},
|
||||||
|
{"watch no www", "https://youtube.com/watch?v=" + id},
|
||||||
|
{"watch m", "https://m.youtube.com/watch?v=" + id},
|
||||||
|
{"watch extra params", "https://www.youtube.com/watch?v=" + id + "&t=42s&list=PLxyz"},
|
||||||
|
{"watch param after", "https://www.youtube.com/watch?list=PLxyz&v=" + id},
|
||||||
|
{"short link", "https://youtu.be/" + id},
|
||||||
|
{"short link param", "https://youtu.be/" + id + "?si=abcd&t=1"},
|
||||||
|
{"shorts", "https://www.youtube.com/shorts/" + id},
|
||||||
|
{"embed", "https://www.youtube.com/embed/" + id},
|
||||||
|
{"bare id", id},
|
||||||
|
{"http scheme", "http://youtube.com/watch?v=" + id},
|
||||||
|
{"no scheme", "youtube.com/watch?v=" + id},
|
||||||
|
{"trailing space", " https://youtu.be/" + id + " "},
|
||||||
|
}
|
||||||
|
for _, c := range ok {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got, err := parseYouTubeVideoID(c.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseYouTubeVideoID(%q) error: %v", c.in, err)
|
||||||
|
}
|
||||||
|
if got != id {
|
||||||
|
t.Fatalf("parseYouTubeVideoID(%q) = %q, want %q", c.in, got, id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := []struct {
|
||||||
|
name, in string
|
||||||
|
}{
|
||||||
|
{"empty", ""},
|
||||||
|
{"blank", " "},
|
||||||
|
{"vimeo", "https://vimeo.com/123456789"},
|
||||||
|
{"other host", "https://example.com/watch?v=" + id},
|
||||||
|
{"watch no id", "https://www.youtube.com/watch?v="},
|
||||||
|
{"short id", "https://youtu.be/abc"},
|
||||||
|
{"long id", "https://youtu.be/" + id + "extra"},
|
||||||
|
{"bad chars", "https://youtu.be/dQw4w9Wg!cQ"},
|
||||||
|
{"not a url", "just some text"},
|
||||||
|
{"channel url", "https://www.youtube.com/@somechannel"},
|
||||||
|
}
|
||||||
|
for _, c := range bad {
|
||||||
|
t.Run("reject "+c.name, func(t *testing.T) {
|
||||||
|
if got, err := parseYouTubeVideoID(c.in); err == nil {
|
||||||
|
t.Fatalf("parseYouTubeVideoID(%q) = %q, want error", c.in, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user