feat: read outlier score and subscribers from VidIQ blocks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
edb450d4e6
commit
d88c06d086
+26
-7
@@ -1,14 +1,33 @@
|
|||||||
// CSS selector of VidIQ's outlier badge on a video card. It stays null until the real
|
import { parseCount } from './numbers.js';
|
||||||
// markup is captured (Task 15); until then readVidiqScore returns null for every card.
|
|
||||||
export const VIDIQ_BADGE_SELECTOR = null;
|
// 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) {
|
export function parseVidiqScore(text) {
|
||||||
const match = /(\d+(?:[.,]\d+)?)/.exec(text ?? '');
|
const match = /(\d+(?:[.,]\d+)?)/.exec(text ?? '');
|
||||||
return match ? Number(match[1].replace(',', '.')) : null;
|
return match ? Number(match[1].replace(',', '.')) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readVidiqScore(card, selector = VIDIQ_BADGE_SELECTOR) {
|
// The badge link carries the exact value (e.g. 146.19) while the badge text is capped (">100x").
|
||||||
if (!selector || !card) return null;
|
export function outlierScoreFromHref(href) {
|
||||||
const badge = card.querySelector(selector);
|
if (!href) return null;
|
||||||
return badge ? parseVidiqScore(badge.textContent) : 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-17
@@ -1,32 +1,71 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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('12.5x'), 12.5);
|
||||||
assert.equal(parseVidiqScore('5,2x'), 5.2);
|
assert.equal(parseVidiqScore('5,2x'), 5.2);
|
||||||
assert.equal(parseVidiqScore('Outlier 8'), 8);
|
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', () => {
|
test('outlierScoreFromHref reads and rounds the outlier_score parameter', () => {
|
||||||
assert.equal(parseVidiqScore('no digits here'), null);
|
assert.equal(outlierScoreFromHref(HREF), 146.19);
|
||||||
assert.equal(parseVidiqScore(''), null);
|
assert.equal(outlierScoreFromHref('https://app.vidiq.com/outliers?outlier_score=2'), 2);
|
||||||
assert.equal(parseVidiqScore(null), null);
|
|
||||||
assert.equal(parseVidiqScore(undefined), null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readVidiqScore returns null while no selector is configured', () => {
|
test('outlierScoreFromHref returns null without a usable parameter', () => {
|
||||||
assert.equal(VIDIQ_BADGE_SELECTOR, null);
|
for (const href of [
|
||||||
const card = { querySelector: () => ({ textContent: '9x' }) };
|
'https://app.vidiq.com/outliers?video_id=x',
|
||||||
assert.equal(readVidiqScore(card), null);
|
'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', () => {
|
// A card stub: querySelector answers only for the two VidIQ selectors.
|
||||||
const card = { querySelector: (selector) => (selector === '.badge' ? { textContent: '7.5x' } : null) };
|
const cardWith = ({ outlier, subscribers }) => ({
|
||||||
assert.equal(readVidiqScore(card, '.badge'), 7.5);
|
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', () => {
|
test('readVidiqData reads the exact score from the link and subscribers from the data attribute', () => {
|
||||||
assert.equal(readVidiqScore({ querySelector: () => null }, '.badge'), null);
|
const card = cardWith({ outlier: outlierNode('>100x', HREF), subscribers: { textContent: '571 тыс.' } });
|
||||||
assert.equal(readVidiqScore(null, '.badge'), null);
|
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 });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user