diff --git a/src/lib/card.js b/src/lib/card.js new file mode 100644 index 0000000..6183d0b --- /dev/null +++ b/src/lib/card.js @@ -0,0 +1,87 @@ +import { parseViews } from './numbers.js'; +import { parseVideoLink } from './video-id.js'; +import { readVidiqData } from './vidiq.js'; + +export const LINK_SELECTOR = 'a[href*="/watch?v="], a[href*="/shorts/"]'; +export const CARD_SELECTOR = [ + 'ytd-rich-item-renderer', + 'ytd-video-renderer', + 'ytd-compact-video-renderer', + 'ytd-grid-video-renderer', + 'yt-lockup-view-model', + 'ytm-shorts-lockup-view-model', + 'ytd-reel-item-renderer', +].join(','); + +// Spans that hold the channel name, the view count and the age in the new and old card layouts. +const METADATA_TEXT = '.ytContentMetadataViewModelMetadataText, .inline-metadata-item, #metadata-line span'; +const CHANNEL_LINK = 'a[href^="/@"], a[href^="/channel/"], a[href^="/c/"], a[href^="/user/"]'; +const RELATIVE_TIME = /назад|(^|\s)ago(\s|$)/i; + +// VidIQ puts its own text (subscribers, VPH, score) inside `.vidiq-scope`; it must not be read as YouTube's. +const outsideVidiq = (element) => !element.closest('.vidiq-scope'); + +function readTitle(card) { + const heading = card.querySelector('h3[title]')?.getAttribute('title')?.trim(); + if (heading) return heading; + const oldTitle = card.querySelector('#video-title'); + const oldText = (oldTitle?.getAttribute('title') || oldTitle?.textContent || '').trim(); + if (oldText) return oldText; + return card.querySelector('.ytLockupMetadataViewModelTitle')?.textContent.trim() || null; +} + +function readThumbnail(card, videoId) { + const image = card.querySelector('img[src^="https://i.ytimg.com/vi/"], img[src^="https://i.ytimg.com/vi_webp/"]'); + return image ? image.src : `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`; +} + +function readViews(card) { + for (const element of card.querySelectorAll(METADATA_TEXT)) { + if (!outsideVidiq(element)) continue; + const views = parseViews(element.textContent) ?? parseViews(element.getAttribute('aria-label')); + if (views !== null) return views; + } + return null; +} + +function absoluteChannelUrl(href) { + try { + const url = new URL(href, 'https://www.youtube.com'); + return url.origin + url.pathname; + } catch { + return null; + } +} + +function readChannel(card, context) { + const link = [...card.querySelectorAll(CHANNEL_LINK)].find((a) => outsideVidiq(a) && a.textContent.trim()); + if (link) return { channelTitle: link.textContent.trim(), channelUrl: absoluteChannelUrl(link.getAttribute('href')) }; + + // New layouts show the channel name as plain text: the first metadata text that is neither a count nor an age. + for (const element of card.querySelectorAll(METADATA_TEXT)) { + const text = element.textContent.trim(); + if (outsideVidiq(element) && text && parseViews(text) === null && !RELATIVE_TIME.test(text)) { + return { channelTitle: text, channelUrl: context?.channelUrl ?? null }; + } + } + return { channelTitle: context?.channelTitle ?? null, channelUrl: context?.channelUrl ?? null }; +} + +// Reads one video card. `context` supplies the channel on channel pages, where cards do not name it. +export function scrapeCard(card, link, context = null) { + const parsed = parseVideoLink(link.getAttribute('href')); + if (!parsed) return null; + const { outlierScore, subscribers } = readVidiqData(card); + const { channelTitle, channelUrl } = readChannel(card, context); + return { + videoId: parsed.videoId, + isShort: parsed.isShort, + title: readTitle(card), + thumbnailUrl: readThumbnail(card, parsed.videoId), + channelTitle, + channelUrl, + subscribers, + views: readViews(card), + outlierScore, + }; +} diff --git a/test/card.dom.test.js b/test/card.dom.test.js new file mode 100644 index 0000000..b890a9b --- /dev/null +++ b/test/card.dom.test.js @@ -0,0 +1,118 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { CHROME_PATH, launchBrowser, startStaticServer } from '../tools/browser.js'; + +const skip = CHROME_PATH ? false : 'Chrome is not installed'; +let server; +let browser; +let page; + +before(async () => { + if (skip) return; + server = await startStaticServer(fileURLToPath(new URL('..', import.meta.url))); + browser = await launchBrowser(); + page = await browser.openPage(`${server.origin}/test/fixtures/harness.html`); +}); + +after(async () => { + page?.close(); + await browser?.close(); + await server?.close(); +}); + +// Puts a fixture into the page (wrapped in `wrapper`, as YouTube nests it), runs `prepare` +// to tweak the DOM, then scrapes the first video link's card. +const scrape = (fixture, { wrapper = 'ytd-rich-item-renderer', prepare = '', context = null } = {}) => + page.evaluate(`(async () => { + const html = await (await fetch('/test/fixtures/${fixture}')).text(); + document.body.innerHTML = '<${wrapper}>' + html + '${wrapper}>'; + ${prepare} + const { scrapeCard, CARD_SELECTOR, LINK_SELECTOR } = await import('/src/lib/card.js'); + const link = document.querySelector(LINK_SELECTOR); + return scrapeCard(link.closest(CARD_SELECTOR), link, ${JSON.stringify(context)}); + })()`); + +test('reads every field from a real home-page card with VidIQ blocks', { skip }, async () => { + const item = await scrape('home-card.html'); + assert.ok(item.thumbnailUrl.startsWith('https://i.ytimg.com/vi/Tts9NHL8D8I/'), item.thumbnailUrl); + assert.deepEqual( + { ...item, thumbnailUrl: null }, + { + videoId: 'Tts9NHL8D8I', + isShort: false, + title: 'Agar.io - 88k Score on an empty Experimental Server', + thumbnailUrl: null, + channelTitle: 'Wun Wun', + channelUrl: 'https://www.youtube.com/@wunwun9522', + subscribers: 571000, + views: 59000000, + outlierScore: 146.19, + }, + ); +}); + +test('home card without VidIQ blocks still has views but no score or subscribers', { skip }, async () => { + const item = await scrape('home-card.html', { + prepare: "document.querySelectorAll('.vidiq-scope').forEach((el) => el.remove());", + }); + assert.equal(item.outlierScore, null); + assert.equal(item.subscribers, null); + assert.equal(item.views, 59000000); +}); + +test('badge without a link falls back to the capped badge text', { skip }, async () => { + const item = await scrape('home-card.html', { + prepare: "document.querySelector('[data-hover-popover=\"OutlierPopover\"] a').removeAttribute('href');", + }); + assert.equal(item.outlierScore, 100); +}); + +test('a /shorts/ link marks the item as a short', { skip }, async () => { + const item = await scrape('home-card.html', { + prepare: "document.querySelector('a[href*=\"/watch?v=\"]').setAttribute('href', '/shorts/abcdefghijk');", + }); + assert.equal(item.videoId, 'abcdefghijk'); + assert.equal(item.isShort, true); +}); + +test('search card: old renderer, subscribers inside the metadata line are not mistaken for views', { skip }, async () => { + const item = await scrape('search-card.html', { wrapper: 'div' }); + assert.deepEqual(item, { + videoId: 'pyV68KvUwlw', + isShort: false, + title: 'Minecraft Beaten In 53 Seconds [FWR]', + thumbnailUrl: 'https://i.ytimg.com/vi/pyV68KvUwlw/hq720.jpg?sqp=abc&rs=def', + channelTitle: 'HanabiYaki', + channelUrl: 'https://www.youtube.com/@HanabiYaki', + subscribers: 111000, + views: 5100000, + outlierScore: 7.46, + }); +}); + +test('sidebar card: channel name without a link, views taken from the metadata row', { skip }, async () => { + const item = await scrape('sidebar-card.html', { wrapper: 'div' }); + assert.equal(item.title, 'CБОРНИК - В Гостях У Бабули 21-30 серии'); + assert.equal(item.channelTitle, 'Уродская Анимация'); + assert.equal(item.channelUrl, null); + assert.equal(item.views, 1800000); + assert.equal(item.subscribers, 2820000); + assert.equal(item.outlierScore, 12); +}); + +test('channel page: the channel comes from the page context when the card names none', { skip }, async () => { + const item = await scrape('sidebar-card.html', { + wrapper: 'div', + prepare: "document.querySelector('.ytContentMetadataViewModelMetadataRow').remove();", + context: { channelTitle: 'MrBeast', channelUrl: 'https://www.youtube.com/@MrBeast' }, + }); + assert.equal(item.channelTitle, 'MrBeast'); + assert.equal(item.channelUrl, 'https://www.youtube.com/@MrBeast'); + assert.equal(item.views, 1800000); +}); + +test('thumbnail falls back to a URL built from the video id', { skip }, async () => { + const item = await scrape('sidebar-card.html', { wrapper: 'div', prepare: "document.querySelector('img').remove();" }); + assert.equal(item.thumbnailUrl, 'https://i.ytimg.com/vi/7KXuP7Ws8W8/mqdefault.jpg'); +}); diff --git a/test/fixtures/harness.html b/test/fixtures/harness.html new file mode 100644 index 0000000..733cab6 --- /dev/null +++ b/test/fixtures/harness.html @@ -0,0 +1,4 @@ + + +
+ 