Resolve YouTube @handles and vanity URLs to a channel ID automatically

YouTube's public Atom feed only accepts a channel_id (or the legacy user
param) — it has no equivalent for the newer @handle format, so pasting a
handle URL straight into the source's url field wouldn't have worked. The
adapter now accepts a bare channel ID, a /channel/UC... URL, an @handle URL,
or a bare handle/username, resolving whichever was given to the actual
channel ID by reading it off the channel page when needed.
This commit is contained in:
Claude
2026-07-21 18:54:43 +00:00
parent e204c70e00
commit d469f00292
2 changed files with 56 additions and 7 deletions
+55 -6
View File
@@ -24,14 +24,63 @@ const parser = new Parser<Record<string, unknown>, YoutubeEntry>({
}
});
/** Builds the channel's Atom feed URL — YouTube publishes these publicly with no API key required. */
function feedUrl(source: Source): string | null {
if (source.url) return source.url;
const USER_AGENT = 'Mozilla/5.0 (compatible; HomefeedBot/1.0; self-hosted RSS reader)';
const CHANNEL_ID_RE = /^UC[\w-]{22}$/;
/**
* Resolves whatever the admin typed into the source's URL field into the channel's
* Atom feed URL. YouTube's public feed endpoint (no API key needed) only accepts a
* `channel_id` (the UC... hash) or the legacy `user` parameter — it has no equivalent
* for the newer `@handle` format, so a handle (or a /c/ or /user/ vanity URL) has to be
* resolved to its real channel ID first by fetching the channel page and pulling the
* ID out of it.
*/
async function resolveFeedUrl(source: Source): Promise<string | null> {
const channelId = source.config?.channelId as string | undefined;
if (channelId) return `https://www.youtube.com/feeds/videos.xml?channel_id=${encodeURIComponent(channelId)}`;
const playlistId = source.config?.playlistId as string | undefined;
if (playlistId) return `https://www.youtube.com/feeds/videos.xml?playlist_id=${encodeURIComponent(playlistId)}`;
return null;
const raw = source.url?.trim();
if (!raw) return null;
if (raw.includes('feeds/videos.xml')) return raw;
if (CHANNEL_ID_RE.test(raw)) return `https://www.youtube.com/feeds/videos.xml?channel_id=${raw}`;
const idInUrl = raw.match(/\/channel\/(UC[\w-]{22})/);
if (idInUrl) return `https://www.youtube.com/feeds/videos.xml?channel_id=${idInUrl[1]}`;
let pageUrl: string;
if (/^https?:\/\//i.test(raw)) {
pageUrl = raw;
} else if (/youtube\.com|youtu\.be/i.test(raw)) {
pageUrl = `https://${raw}`;
} else {
// A bare handle or legacy username typed on its own, e.g. "PsyopAnime" or "@PsyopAnime".
pageUrl = `https://www.youtube.com/@${raw.replace(/^@/, '')}`;
}
const resolvedId = await resolveChannelIdFromPage(pageUrl);
if (!resolvedId) {
logger.warn('youtube', `Couldn't resolve a channel ID from "${raw}" — double-check the handle/URL`);
return null;
}
return `https://www.youtube.com/feeds/videos.xml?channel_id=${resolvedId}`;
}
/** Every YouTube channel page embeds its own canonical channel ID — pulled from either the canonical link tag or the page's inline JSON. */
async function resolveChannelIdFromPage(url: string): Promise<string | null> {
try {
const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT }, signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
const html = await res.text();
const match = html.match(/"channelId":"(UC[\w-]{22})"/) || html.match(/\/channel\/(UC[\w-]{22})/);
return match ? match[1] : null;
} catch (err) {
logger.warn('youtube', `Channel page fetch failed for ${url}: ${(err as Error).message}`);
return null;
}
}
function extractVideoId(url: string): string | null {
@@ -52,9 +101,9 @@ function extractThumbnail(item: YoutubeEntry): string | null {
export const youtubeAdapter: SourceAdapter = {
async fetch(source: Source): Promise<FetchedItem[]> {
const url = feedUrl(source);
const url = await resolveFeedUrl(source);
if (!url) {
logger.warn('youtube', `Source "${source.name}" has no url, channelId, or playlistId configured — skipping`);
logger.warn('youtube', `Source "${source.name}" has no usable url, channelId, or playlistId configured — skipping`);
return [];
}
@@ -157,7 +157,7 @@
<option value="custom">Custom</option>
</select>
{#if form.type === 'youtube'}
<input placeholder="Channel ID, playlist ID, or full feed URL" bind:value={form.channelId} />
<input placeholder="Channel URL (@handle or /channel/UC…), or channel ID" bind:value={form.channelId} />
{:else}
<input placeholder="URL or channel" bind:value={form.url} />
{/if}