feat: parse YouTube video links and page types
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
05d8bff171
commit
8dbb0cf9ce
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "outlier-saver-extension",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const ID_RE = /^[A-Za-z0-9_-]{11}$/;
|
||||||
|
const YOUTUBE_HOSTS = new Set(['www.youtube.com', 'youtube.com', 'm.youtube.com']);
|
||||||
|
|
||||||
|
export function isVideoId(value) {
|
||||||
|
return typeof value === 'string' && ID_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { videoId, isShort } for a link to a single video, or null for anything else.
|
||||||
|
export function parseVideoLink(href, base = 'https://www.youtube.com') {
|
||||||
|
if (!href) return null;
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(href, base);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!YOUTUBE_HOSTS.has(url.hostname)) return null;
|
||||||
|
|
||||||
|
if (url.pathname === '/watch') {
|
||||||
|
const id = url.searchParams.get('v');
|
||||||
|
return isVideoId(id) ? { videoId: id, isShort: false } : null;
|
||||||
|
}
|
||||||
|
const shorts = url.pathname.match(/^\/shorts\/([^/]+)/);
|
||||||
|
if (shorts && isVideoId(shorts[1])) return { videoId: shorts[1], isShort: true };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageFromPath(pathname) {
|
||||||
|
if (pathname === '/') return 'home';
|
||||||
|
if (pathname === '/watch') return 'watch';
|
||||||
|
if (pathname === '/results') return 'search';
|
||||||
|
if (/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)/.test(pathname)) return 'channel';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { isVideoId, pageFromPath, parseVideoLink } from '../src/lib/video-id.js';
|
||||||
|
|
||||||
|
test('isVideoId accepts only 11-character IDs', () => {
|
||||||
|
assert.equal(isVideoId('dQw4w9WgXcQ'), true);
|
||||||
|
assert.equal(isVideoId('abc_DEF-123'), true);
|
||||||
|
for (const value of ['short', 'waytoolongvideoid', 'has space!!', '', null, undefined, 12345678901]) {
|
||||||
|
assert.equal(isVideoId(value), false, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseVideoLink reads an absolute watch URL', () => {
|
||||||
|
assert.deepEqual(parseVideoLink('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), {
|
||||||
|
videoId: 'dQw4w9WgXcQ',
|
||||||
|
isShort: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseVideoLink reads a relative watch href with extra params', () => {
|
||||||
|
assert.deepEqual(parseVideoLink('/watch?v=dQw4w9WgXcQ&t=42s&list=PL123'), {
|
||||||
|
videoId: 'dQw4w9WgXcQ',
|
||||||
|
isShort: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseVideoLink marks /shorts/ links as shorts', () => {
|
||||||
|
assert.deepEqual(parseVideoLink('/shorts/abcdefghijk'), { videoId: 'abcdefghijk', isShort: true });
|
||||||
|
assert.deepEqual(parseVideoLink('/shorts/abcdefghijk?feature=share'), {
|
||||||
|
videoId: 'abcdefghijk',
|
||||||
|
isShort: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseVideoLink ignores links that are not a single video', () => {
|
||||||
|
for (const href of ['/', '/@mrbeast', '/playlist?list=PL123', '/feed/subscriptions', '/results?search_query=x']) {
|
||||||
|
assert.equal(parseVideoLink(href), null, href);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseVideoLink rejects malformed IDs', () => {
|
||||||
|
for (const href of [
|
||||||
|
'/watch',
|
||||||
|
'/watch?v=',
|
||||||
|
'/watch?v=short',
|
||||||
|
'/watch?v=waytoolongvideoid',
|
||||||
|
'/shorts/toolongidtoolong',
|
||||||
|
]) {
|
||||||
|
assert.equal(parseVideoLink(href), null, href);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseVideoLink rejects other hosts and garbage', () => {
|
||||||
|
assert.equal(parseVideoLink('https://evil.example.com/watch?v=dQw4w9WgXcQ'), null);
|
||||||
|
for (const href of [null, undefined, '', 'http://[bad']) {
|
||||||
|
assert.equal(parseVideoLink(href), null, String(href));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pageFromPath maps the four supported page types', () => {
|
||||||
|
assert.equal(pageFromPath('/'), 'home');
|
||||||
|
assert.equal(pageFromPath('/watch'), 'watch');
|
||||||
|
assert.equal(pageFromPath('/results'), 'search');
|
||||||
|
for (const path of ['/@mrbeast', '/@mrbeast/videos', '/channel/UC123/videos', '/c/name', '/user/name']) {
|
||||||
|
assert.equal(pageFromPath(path), 'channel', path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pageFromPath returns null for unsupported pages', () => {
|
||||||
|
for (const path of ['/feed/subscriptions', '/shorts/abcdefghijk', '/playlist', '/premium']) {
|
||||||
|
assert.equal(pageFromPath(path), null, path);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user