From d88c06d0865093034f13998f7db31cd9fb416eb5 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sun, 25 Oct 2026 12:04:55 +0300 Subject: [PATCH] feat: read outlier score and subscribers from VidIQ blocks Co-Authored-By: Claude Sonnet 5.5 --- src/lib/vidiq.js | 33 ++++++++++++++++----- test/vidiq.test.js | 73 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 24 deletions(-) diff --git a/src/lib/vidiq.js b/src/lib/vidiq.js index b2b1942..141ecdd 100644 --- a/src/lib/vidiq.js +++ b/src/lib/vidiq.js @@ -1,14 +1,33 @@ -// CSS selector of VidIQ's outlier badge on a video card. It stays null until the real -// markup is captured (Task 15); until then readVidiqScore returns null for every card. -export const VIDIQ_BADGE_SELECTOR = null; +import { parseCount } from './numbers.js'; + +// VidIQ's own class names are generated (vidiq-c-xxxx) and change between builds; these hooks are +// data attributes it sets deliberately. +export const OUTLIER_SELECTOR = '[data-hover-popover="OutlierPopover"]'; +export const SUBSCRIBERS_SELECTOR = '[data-vidiq-subscriber-count]'; export function parseVidiqScore(text) { const match = /(\d+(?:[.,]\d+)?)/.exec(text ?? ''); return match ? Number(match[1].replace(',', '.')) : null; } -export function readVidiqScore(card, selector = VIDIQ_BADGE_SELECTOR) { - if (!selector || !card) return null; - const badge = card.querySelector(selector); - return badge ? parseVidiqScore(badge.textContent) : null; +// The badge link carries the exact value (e.g. 146.19) while the badge text is capped (">100x"). +export function outlierScoreFromHref(href) { + if (!href) return null; + let raw; + try { + raw = new URL(href, 'https://app.vidiq.com').searchParams.get('outlier_score'); + } catch { + return null; + } + if (raw === null || raw === '') return null; + const value = Number(raw); + return Number.isFinite(value) ? Math.round(value * 100) / 100 : null; +} + +export function readVidiqData(card) { + const outlier = card.querySelector(OUTLIER_SELECTOR); + const href = outlier?.querySelector('a')?.getAttribute('href'); + const outlierScore = outlier ? (outlierScoreFromHref(href) ?? parseVidiqScore(outlier.textContent)) : null; + const subscribersNode = card.querySelector(SUBSCRIBERS_SELECTOR); + return { outlierScore, subscribers: subscribersNode ? parseCount(subscribersNode.textContent) : null }; } diff --git a/test/vidiq.test.js b/test/vidiq.test.js index b274d71..39248d5 100644 --- a/test/vidiq.test.js +++ b/test/vidiq.test.js @@ -1,32 +1,71 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { VIDIQ_BADGE_SELECTOR, parseVidiqScore, readVidiqScore } from '../src/lib/vidiq.js'; +import { + OUTLIER_SELECTOR, + SUBSCRIBERS_SELECTOR, + outlierScoreFromHref, + parseVidiqScore, + readVidiqData, +} from '../src/lib/vidiq.js'; -test('parseVidiqScore extracts the first number', () => { +const HREF = + 'https://app.vidiq.com/outliers?utm_source=chrome&utm_medium=extension&utm_content=&filters=%7B%22mode%22%3A%22long%22%7D' + + '&outlier_score=146.19205697700949&video_id=Tts9NHL8D8I'; + +test('selectors point at VidIQ data attributes, not generated classes', () => { + assert.equal(OUTLIER_SELECTOR, '[data-hover-popover="OutlierPopover"]'); + assert.equal(SUBSCRIBERS_SELECTOR, '[data-vidiq-subscriber-count]'); +}); + +test('parseVidiqScore takes the first number in the text', () => { + assert.equal(parseVidiqScore('>100x'), 100); assert.equal(parseVidiqScore('12.5x'), 12.5); assert.equal(parseVidiqScore('5,2x'), 5.2); assert.equal(parseVidiqScore('Outlier 8'), 8); + for (const text of ['no digits', '', null, undefined]) assert.equal(parseVidiqScore(text), null, String(text)); }); -test('parseVidiqScore returns null when there is no number', () => { - assert.equal(parseVidiqScore('no digits here'), null); - assert.equal(parseVidiqScore(''), null); - assert.equal(parseVidiqScore(null), null); - assert.equal(parseVidiqScore(undefined), null); +test('outlierScoreFromHref reads and rounds the outlier_score parameter', () => { + assert.equal(outlierScoreFromHref(HREF), 146.19); + assert.equal(outlierScoreFromHref('https://app.vidiq.com/outliers?outlier_score=2'), 2); }); -test('readVidiqScore returns null while no selector is configured', () => { - assert.equal(VIDIQ_BADGE_SELECTOR, null); - const card = { querySelector: () => ({ textContent: '9x' }) }; - assert.equal(readVidiqScore(card), null); +test('outlierScoreFromHref returns null without a usable parameter', () => { + for (const href of [ + 'https://app.vidiq.com/outliers?video_id=x', + 'https://app.vidiq.com/outliers?outlier_score=', + 'https://app.vidiq.com/outliers?outlier_score=abc', + '', + null, + undefined, + ]) { + assert.equal(outlierScoreFromHref(href), null, String(href)); + } }); -test('readVidiqScore reads the badge inside a card', () => { - const card = { querySelector: (selector) => (selector === '.badge' ? { textContent: '7.5x' } : null) }; - assert.equal(readVidiqScore(card, '.badge'), 7.5); +// A card stub: querySelector answers only for the two VidIQ selectors. +const cardWith = ({ outlier, subscribers }) => ({ + querySelector: (selector) => + selector === OUTLIER_SELECTOR ? outlier ?? null : selector === SUBSCRIBERS_SELECTOR ? subscribers ?? null : null, +}); +const outlierNode = (text, href) => ({ + textContent: text, + querySelector: (selector) => + selector === 'a' && href !== undefined ? { getAttribute: (name) => (name === 'href' ? href : null) } : null, }); -test('readVidiqScore returns null when the card has no badge or no card is given', () => { - assert.equal(readVidiqScore({ querySelector: () => null }, '.badge'), null); - assert.equal(readVidiqScore(null, '.badge'), null); +test('readVidiqData reads the exact score from the link and subscribers from the data attribute', () => { + const card = cardWith({ outlier: outlierNode('>100x', HREF), subscribers: { textContent: '571 тыс.' } }); + assert.deepEqual(readVidiqData(card), { outlierScore: 146.19, subscribers: 571000 }); +}); + +test('readVidiqData falls back to the badge text when the link has no score', () => { + const card = cardWith({ outlier: outlierNode('>100x', 'https://app.vidiq.com/outliers'), subscribers: null }); + assert.deepEqual(readVidiqData(card), { outlierScore: 100, subscribers: null }); + const noLink = cardWith({ outlier: outlierNode('7.5x'), subscribers: null }); + assert.deepEqual(readVidiqData(noLink), { outlierScore: 7.5, subscribers: null }); +}); + +test('readVidiqData returns nulls when VidIQ has not rendered its blocks', () => { + assert.deepEqual(readVidiqData(cardWith({})), { outlierScore: null, subscribers: null }); });