feat: add record building with threshold and input validation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
d88c06d086
commit
386b88bab5
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user