From 386b88bab551a7f78da8997beef66ca4d0cd294b Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sun, 25 Oct 2026 12:04:55 +0300 Subject: [PATCH] feat: add record building with threshold and input validation Co-Authored-By: Claude Sonnet 5.5 --- src/lib/record.js | 53 ++++++++++++++++++ test/record.test.js | 131 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/lib/record.js create mode 100644 test/record.test.js diff --git a/src/lib/record.js b/src/lib/record.js new file mode 100644 index 0000000..be83c8f --- /dev/null +++ b/src/lib/record.js @@ -0,0 +1,53 @@ +import { isVideoId } from './video-id.js'; + +const PAGES = new Set(['home', 'watch', 'search', 'channel']); +const MAX_TEXT = 300; + +export function shouldSave({ views, outlierScore }, { threshold, minViews }) { + return typeof outlierScore === 'number' && outlierScore >= threshold && views >= minViews; +} + +const finiteOrNull = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null); +const textOrNull = (value) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, MAX_TEXT) : null); +const urlOrNull = (value, prefix) => (typeof value === 'string' && value.startsWith(prefix) ? value : null); + +// Validates a message coming from the content script before anything is stored. +export function sanitizeItem(raw) { + if (!raw || !isVideoId(raw.videoId)) return null; + const outlierScore = finiteOrNull(raw.outlierScore); + const views = finiteOrNull(raw.views); + if (outlierScore === null || outlierScore < 0 || views === null || views < 0) return null; + const subscribers = finiteOrNull(raw.subscribers); + return { + videoId: raw.videoId, + title: textOrNull(raw.title), + thumbnailUrl: urlOrNull(raw.thumbnailUrl, 'https://i.ytimg.com/'), + channelTitle: textOrNull(raw.channelTitle), + channelUrl: urlOrNull(raw.channelUrl, 'https://www.youtube.com/'), + subscribers: subscribers !== null && subscribers >= 0 ? subscribers : null, + views, + outlierScore: Math.round(outlierScore * 100) / 100, + isShort: raw.isShort === true, + page: PAGES.has(raw.page) ? raw.page : 'home', + }; +} + +export function buildRecord(item, previous, settings, now = new Date()) { + if (!previous && !shouldSave(item, settings)) return null; + const nowIso = now.toISOString(); + return { + videoId: item.videoId, + title: item.title ?? previous?.title ?? item.videoId, + url: `https://www.youtube.com/watch?v=${item.videoId}`, + thumbnailUrl: item.thumbnailUrl ?? previous?.thumbnailUrl ?? null, + channelTitle: item.channelTitle ?? previous?.channelTitle ?? null, + channelUrl: item.channelUrl ?? previous?.channelUrl ?? null, + subscribers: item.subscribers ?? previous?.subscribers ?? null, + views: item.views, + outlierScore: item.outlierScore, + isShort: item.isShort || previous?.isShort || false, + foundOn: previous?.foundOn ?? item.page, + firstSeenAt: previous?.firstSeenAt ?? nowIso, + updatedAt: nowIso, + }; +} diff --git a/test/record.test.js b/test/record.test.js new file mode 100644 index 0000000..77ae739 --- /dev/null +++ b/test/record.test.js @@ -0,0 +1,131 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildRecord, sanitizeItem, shouldSave } from '../src/lib/record.js'; + +const settings = { threshold: 5, minViews: 10000 }; +const NOW = new Date('2026-10-25T10:00:00.000Z'); +const NOW_ISO = NOW.toISOString(); + +test('shouldSave accepts a video exactly at both limits', () => { + assert.equal(shouldSave({ views: 10000, outlierScore: 5 }, settings), true); +}); + +test('shouldSave rejects a low score, low views or a missing score', () => { + assert.equal(shouldSave({ views: 50000, outlierScore: 4.99 }, settings), false); + assert.equal(shouldSave({ views: 9999, outlierScore: 150 }, settings), false); + assert.equal(shouldSave({ views: 50000, outlierScore: null }, settings), false); +}); + +const raw = (over = {}) => ({ + videoId: 'Tts9NHL8D8I', + title: 'Agar.io - 88k Score', + thumbnailUrl: 'https://i.ytimg.com/vi/Tts9NHL8D8I/hq720.jpg', + channelTitle: 'Wun Wun', + channelUrl: 'https://www.youtube.com/@wunwun9522', + subscribers: 571000, + views: 59000000, + outlierScore: 146.19, + isShort: false, + page: 'home', + ...over, +}); + +test('sanitizeItem passes a valid item through unchanged', () => { + assert.deepEqual(sanitizeItem(raw()), raw()); +}); + +test('sanitizeItem rejects items without a usable id, score or views', () => { + for (const bad of [ + null, + undefined, + 'x', + {}, + raw({ videoId: 'short' }), + raw({ outlierScore: NaN }), + raw({ outlierScore: null }), + raw({ outlierScore: -1 }), + raw({ views: null }), + raw({ views: -5 }), + raw({ views: '5' }), + ]) { + assert.equal(sanitizeItem(bad), null, JSON.stringify(bad)); + } +}); + +test('sanitizeItem nulls out unusable optional fields', () => { + const item = sanitizeItem( + raw({ + title: ' ', + thumbnailUrl: 'javascript:alert(1)', + channelTitle: 5, + channelUrl: 'https://evil.example.com/@x', + subscribers: -3, + isShort: 'yes', + page: 'nowhere', + }), + ); + assert.deepEqual( + item, + raw({ title: null, thumbnailUrl: null, channelTitle: null, channelUrl: null, subscribers: null, isShort: false, page: 'home' }), + ); +}); + +test('sanitizeItem trims and caps the title and rounds the score', () => { + const item = sanitizeItem(raw({ title: ` ${'x'.repeat(400)} `, outlierScore: 12.3456 })); + assert.equal(item.title.length, 300); + assert.equal(item.outlierScore, 12.35); +}); + +test('buildRecord creates a full record for a new outlier', () => { + assert.deepEqual(buildRecord(sanitizeItem(raw()), undefined, settings, NOW), { + videoId: 'Tts9NHL8D8I', + title: 'Agar.io - 88k Score', + url: 'https://www.youtube.com/watch?v=Tts9NHL8D8I', + thumbnailUrl: 'https://i.ytimg.com/vi/Tts9NHL8D8I/hq720.jpg', + channelTitle: 'Wun Wun', + channelUrl: 'https://www.youtube.com/@wunwun9522', + subscribers: 571000, + views: 59000000, + outlierScore: 146.19, + isShort: false, + foundOn: 'home', + firstSeenAt: NOW_ISO, + updatedAt: NOW_ISO, + }); +}); + +test('buildRecord skips a new video below the threshold', () => { + assert.equal(buildRecord(sanitizeItem(raw({ outlierScore: 2 })), undefined, settings, NOW), null); + assert.equal(buildRecord(sanitizeItem(raw({ views: 500 })), undefined, settings, NOW), null); +}); + +test('buildRecord keeps updating an already saved video even below the threshold', () => { + const previous = buildRecord(sanitizeItem(raw()), undefined, settings, new Date('2026-10-20T00:00:00.000Z')); + const updated = buildRecord(sanitizeItem(raw({ outlierScore: 1.5, views: 60000000, page: 'search' })), previous, settings, NOW); + assert.equal(updated.outlierScore, 1.5); + assert.equal(updated.views, 60000000); + assert.equal(updated.updatedAt, NOW_ISO); + assert.equal(updated.firstSeenAt, '2026-10-20T00:00:00.000Z'); + assert.equal(updated.foundOn, 'home'); +}); + +test('buildRecord keeps known values when the new sighting lacks them', () => { + const previous = buildRecord(sanitizeItem(raw({ isShort: true })), undefined, settings, new Date('2026-10-20T00:00:00.000Z')); + const updated = buildRecord( + sanitizeItem(raw({ subscribers: null, channelTitle: null, channelUrl: null, thumbnailUrl: null, title: null, isShort: false })), + previous, + settings, + NOW, + ); + assert.equal(updated.subscribers, 571000); + assert.equal(updated.channelTitle, 'Wun Wun'); + assert.equal(updated.channelUrl, 'https://www.youtube.com/@wunwun9522'); + assert.equal(updated.thumbnailUrl, 'https://i.ytimg.com/vi/Tts9NHL8D8I/hq720.jpg'); + assert.equal(updated.title, 'Agar.io - 88k Score'); + assert.equal(updated.isShort, true); +}); + +test('buildRecord falls back to the id when a new video has no title', () => { + const record = buildRecord(sanitizeItem(raw({ title: null })), undefined, settings, NOW); + assert.equal(record.title, 'Tts9NHL8D8I'); +});