Dynamic import() of extension modules from a content script makes Chrome log 'Unsafe attempt to load URL chrome-extension://...' on YouTube pages, which fills the extension's error list even though the modules load (HTTP 200). The content script is now one classic file built by 'npm run build'; web_accessible_resources is no longer needed and is removed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
72 KiB
Outlier Saver: переход на данные VidIQ — план реализации
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Переделать готовое расширение с YouTube Data API на чтение DOM: outlier score и подписчиков брать из блоков VidIQ на карточках видео, просмотры, название, канал и превью — из разметки YouTube.
Architecture: Content script разбирает карточки (scrapeCard) и шлёт в background только те, где VidIQ уже вывел score. Background проверяет сообщение, сравнивает с порогом и пишет в IndexedDB. Сети, ключей и очередей больше нет. Вся логика, кроме работы с DOM, — чистые функции под node:test; разбор карточки проверяется в headless Chrome на фикстурах.
Tech Stack: Chrome Manifest V3, чистый JavaScript (ES-модули), IndexedDB, node:test (Node 22), headless Chrome только для одного теста (пропускается, если Chrome нет). Без npm-зависимостей и сборки.
Global Constraints
Из docs/superpowers/specs/2026-10-25-outlier-saver-design.md (версия 2). Каждая задача неявно включает эти пункты.
- Manifest V3, чистый JS с ES-модулями, без сборки и без зависимостей; расширение загружается как «распакованное».
- Тесты: только встроенный
node:test(npm test→node --test). - Никакой сети. В манифесте нет
host_permissions; нетfetchк YouTube, VidIQ или API. Разрешения:storage. - Сохраняем видео, если
outlierScore >= thresholdиviews >= minViews. Уже сохранённое видео обновляется всегда. Настройки (chrome.storage.local, ключsettings):thresholdпо умолчанию 5,minViewsпо умолчанию 10 000. - Привязка к VidIQ только через
[data-hover-popover="OutlierPopover"](+ параметрoutlier_scoreв его ссылке),[data-vidiq-subscriber-count]и контейнер.vidiq-scope. Сгенерированные классы видаvidiq-c-…не использовать. - Число подписчиков и просмотров — значения с экрана (округлённые); языки интерфейса: русский и английский.
- IndexedDB: база
outlier-saver, версия 1, хранилищеoutliers(ключvideoId, индексupdatedAt). urlзаписи всегдаhttps://www.youtube.com/watch?v=<videoId>, включая Shorts.isShort—true, если видео хоть раз встретилось по ссылке/shorts/.- Сканирование страниц: главная
/, просмотр/watch, поиск/results, канал/@…,/channel/…,/c/…,/user/…. Собираются только карточки (не само открытое видео). - Тексты интерфейса — на русском. Недоверенный текст (названия, каналы) выводится через
textContent, не черезinnerHTML. - Сообщения коммитов заканчиваются трейлером
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>(вторым-m). - Временные файлы тестов создаются через
os.tmpdir(); при запуске из этой среды выставлятьTMPDIRна scratchpad.
Структура файлов
manifest.json # изменить: без host_permissions, web_accessible_resources: src/lib/*.js
src/background.js # переписать: принять карточки -> buildRecord -> IndexedDB
src/content.js # переписать: сканер карточек
src/lib/numbers.js # новый: parseCount, parseViews
src/lib/vidiq.js # переписать: селекторы VidIQ, outlierScoreFromHref, readVidiqData
src/lib/record.js # новый: shouldSave, sanitizeItem, buildRecord
src/lib/card.js # новый: scrapeCard, CARD_SELECTOR, LINK_SELECTOR
src/lib/video-id.js # без изменений
src/lib/export.js # изменить: колонки
src/lib/storage.js # изменить: только threshold и minViews, без status
src/lib/db.js # изменить: только хранилище outliers
src/lib/api.js, process.js, score.js # удалить
src/popup/*, src/options/* # изменить: убрать ключ и статус
tools/browser.js # новый: запуск headless Chrome и статического сервера для теста
test/fixtures/home-card.html # уже лежит: настоящая карточка с главной с блоками VidIQ
test/fixtures/{harness,search-card,sidebar-card}.html # новые
test/{numbers,vidiq,record,card.dom,export,storage}.test.js
test/{api-helpers,api-requests,process,score}.test.js # удалить
README.md # обновить
Форма сообщения content → background ({ type: 'cards', items: Item[] }), Item:
{ videoId, title: string|null, thumbnailUrl: string|null, channelTitle: string|null, channelUrl: string|null, subscribers: number|null, views: number, outlierScore: number, isShort: boolean, page: 'home'|'watch'|'search'|'channel' }.
Task 1: Разбор чисел («59 млн», «571 тыс.», «1.2M»)
Files:
- Create:
src/lib/numbers.js - Test:
test/numbers.test.js
Interfaces:
-
Consumes: ничего.
-
Produces:
parseCount(text: unknown): number | null— целое ≥ 0 илиnull. Принимает число с необязательной единицей и необязательным словом «просмотров / подписчиков / views / subscribers». Весь текст должен разобраться целиком.parseViews(text: unknown): number | null— то же, плюс «Нет просмотров» / «No views» →0.
-
Step 1: Write the failing test
test/numbers.test.js:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseCount, parseViews } from '../src/lib/numbers.js';
test('parseCount understands Russian abbreviations', () => {
assert.equal(parseCount('59 млн'), 59000000);
assert.equal(parseCount('59 млн'), 59000000); // non-breaking space, as YouTube renders it
assert.equal(parseCount('571 тыс.'), 571000);
assert.equal(parseCount('1,8 млн'), 1800000);
assert.equal(parseCount('3,4 млрд'), 3400000000);
assert.equal(parseCount('123 тысячи'), 123000);
assert.equal(parseCount('1,8 миллиона'), 1800000);
assert.equal(parseCount('146 миллионов'), 146000000);
});
test('parseCount understands English abbreviations', () => {
assert.equal(parseCount('1.2M'), 1200000);
assert.equal(parseCount('1.2m'), 1200000);
assert.equal(parseCount('123K'), 123000);
assert.equal(parseCount('1.5B'), 1500000000);
assert.equal(parseCount('2.5 million'), 2500000);
});
test('parseCount reads plain and grouped integers', () => {
assert.equal(parseCount('37'), 37);
assert.equal(parseCount('0'), 0);
assert.equal(parseCount('12 345'), 12345);
assert.equal(parseCount('12 345'), 12345);
assert.equal(parseCount('1,234,567'), 1234567);
assert.equal(parseCount('1.234.567'), 1234567);
});
test('parseCount ignores trailing "views" and "subscribers" words', () => {
assert.equal(parseCount('59 миллионов просмотров'), 59000000);
assert.equal(parseCount('1 просмотр'), 1);
assert.equal(parseCount('571 тыс. подписчиков'), 571000);
assert.equal(parseCount('1.2M views'), 1200000);
assert.equal(parseCount('1.2M subscribers'), 1200000);
});
test('parseCount rejects anything that is not wholly a count', () => {
for (const text of ['', 'Wun Wun', '10 л. назад', '2 г. назад', '7 дн. назад', '3 нед. назад', '1,2 тыс. зрителей', '5 Minute Crafts', 'abc', '1.2', '1,5']) {
assert.equal(parseCount(text), null, text);
}
for (const value of [null, undefined, 123, {}]) {
assert.equal(parseCount(value), null, String(value));
}
});
test('parseViews adds the "no views" cases', () => {
assert.equal(parseViews('Нет просмотров'), 0);
assert.equal(parseViews('No views'), 0);
assert.equal(parseViews('59 млн'), 59000000);
assert.equal(parseViews('1,8 млн просмотров'), 1800000);
assert.equal(parseViews('1.2M views'), 1200000);
});
test('parseViews rejects dates and names', () => {
for (const text of ['10 л. назад', 'Wun Wun', '2 years ago', null, undefined]) {
assert.equal(parseViews(text), null, String(text));
}
});
- Step 2: Run the test to verify it fails
Run: node --test test/numbers.test.js
Expected: FAIL with ERR_MODULE_NOT_FOUND.
- Step 3: Write the implementation
src/lib/numbers.js:
const UNITS = new Map([
['k', 1e3], ['тыс', 1e3], ['тысяча', 1e3], ['тысячи', 1e3], ['тысяч', 1e3], ['thousand', 1e3],
['m', 1e6], ['млн', 1e6], ['миллион', 1e6], ['миллиона', 1e6], ['миллионов', 1e6], ['million', 1e6],
['b', 1e9], ['млрд', 1e9], ['миллиард', 1e9], ['миллиарда', 1e9], ['миллиардов', 1e9], ['billion', 1e9],
]);
// "просмотров", "подписчиков", "views", "subscribers" and their other forms at the end of the text.
const COUNT_WORDS = /\s*(просмотров|просмотра|просмотр|подписчиков|подписчика|подписчики|подписчик|views?|subscribers?)$/;
const GROUPED_INTEGER = /^\d{1,3}(?:[ ,.]\d{3})+$/;
const DECIMAL = /^\d+(?:[.,]\d+)?$/;
// Reads "59 млн", "571 тыс.", "1.2M", "12 345" and the like; the whole text must be a count.
export function parseCount(text) {
if (typeof text !== 'string') return null;
const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase().replace(COUNT_WORDS, '').trim();
const match = /^(\d[\d ,.]*?)(?: ?([a-zа-яё]+)\.?)?$/.exec(normalized);
if (!match) return null;
const [, number, unit] = match;
if (unit === undefined) {
if (/^\d+$/.test(number)) return Number(number);
return GROUPED_INTEGER.test(number) ? Number(number.replace(/\D/g, '')) : null;
}
const multiplier = UNITS.get(unit);
if (multiplier === undefined || !DECIMAL.test(number)) return null;
return Math.round(Number(number.replace(',', '.')) * multiplier);
}
export function parseViews(text) {
if (typeof text !== 'string') return null;
if (/^\s*(no|нет) (views|просмотров)\s*$/i.test(text)) return 0;
return parseCount(text);
}
- Step 4: Run the test to verify it passes
Run: node --test test/numbers.test.js
Expected: PASS, 7 tests.
- Step 5: Commit
git add src/lib/numbers.js test/numbers.test.js
git commit -m "feat: parse abbreviated counts like \"59 млн\" and \"571 тыс.\"" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 2: Чтение данных VidIQ с карточки
Files:
- Modify (rewrite):
src/lib/vidiq.js - Modify (rewrite):
test/vidiq.test.js
Interfaces:
-
Consumes:
parseCountfrom./numbers.js. -
Produces:
OUTLIER_SELECTOR = '[data-hover-popover="OutlierPopover"]',SUBSCRIBERS_SELECTOR = '[data-vidiq-subscriber-count]'parseVidiqScore(text: string|null|undefined): number|null— первое число в тексте (>100x→ 100)outlierScoreFromHref(href: string|null|undefined): number|null— параметрoutlier_score, округлённый до двух знаковreadVidiqData(card): { outlierScore: number|null, subscribers: number|null };card— любой объект сquerySelector(selector); найденные узлы используютtextContent,querySelector('a')иgetAttribute('href').
-
Step 1: Write the failing test
Replace test/vidiq.test.js:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
OUTLIER_SELECTOR,
SUBSCRIBERS_SELECTOR,
outlierScoreFromHref,
parseVidiqScore,
readVidiqData,
} from '../src/lib/vidiq.js';
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('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('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));
}
});
// 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('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 });
});
- Step 2: Run the test to verify it fails
Run: node --test test/vidiq.test.js
Expected: FAIL (does not provide an export named 'OUTLIER_SELECTOR').
- Step 3: Write the implementation
Replace src/lib/vidiq.js:
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;
}
// 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 };
}
- Step 4: Run the test to verify it passes
Run: node --test test/vidiq.test.js
Expected: PASS, 7 tests.
- Step 5: Commit
git add src/lib/vidiq.js test/vidiq.test.js
git commit -m "feat: read outlier score and subscribers from VidIQ blocks" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 3: Сохранение записи (порог, проверка входа, сборка записи)
Files:
- Create:
src/lib/record.js - Test:
test/record.test.js
Interfaces:
-
Consumes:
isVideoIdfrom./video-id.js. -
Produces:
shouldSave({ views, outlierScore }, { threshold, minViews }): booleansanitizeItem(raw: unknown): Item | null—Itemкак в разделе «Структура файлов»; возвращаетnull, если нет корректныхvideoId,outlierScore(число ≥ 0) илиviews(число ≥ 0); невалидные необязательные поля становятсяnullbuildRecord(item: Item, previous: Outlier | undefined, settings: { threshold, minViews }, now?: Date): Outlier | null— запись с 13 полями из спецификации;null, если видео новое и не проходит порог
-
Step 1: Write the failing test
test/record.test.js:
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');
});
- Step 2: Run the test to verify it fails
Run: node --test test/record.test.js
Expected: FAIL with ERR_MODULE_NOT_FOUND.
- Step 3: Write the implementation
src/lib/record.js:
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,
};
}
- Step 4: Run the test to verify it passes
Run: node --test test/record.test.js
Expected: PASS, 10 tests.
- Step 5: Commit
git add src/lib/record.js test/record.test.js
git commit -m "feat: add record building with threshold and input validation" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 4: Разбор карточки и тест в headless Chrome
Files:
- Create:
src/lib/card.js,tools/browser.js - Create:
test/fixtures/harness.html,test/fixtures/search-card.html,test/fixtures/sidebar-card.html - Existing:
test/fixtures/home-card.html(настоящий образец от пользователя) - Test:
test/card.dom.test.js
Interfaces:
-
Consumes:
parseVideoLink(video-id.js),readVidiqData(vidiq.js),parseViews(numbers.js). -
Produces:
LINK_SELECTOR,CARD_SELECTOR(строки CSS-селекторов)scrapeCard(card: Element, link: Element, context?: { channelTitle?: string|null, channelUrl?: string|null } | null): { videoId, isShort, title, thumbnailUrl, channelTitle, channelUrl, subscribers, views, outlierScore } | null—null, если у ссылки нет корректного ID; поля, которые не удалось прочитать, равныnulltools/browser.js:CHROME_PATH: string|null,startStaticServer(root): Promise<{ origin, close }>,launchBrowser(): Promise<{ openPage(url): Promise<{ evaluate(expr), close() }>, close() }>
-
Step 1: Create the helper and the fixtures
tools/browser.js:
import { spawn, spawnSync } from 'node:child_process';
import { createServer } from 'node:http';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { extname, join, resolve, sep } from 'node:path';
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
export const CHROME_PATH =
['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'].find(
(name) => spawnSync('which', [name], { stdio: 'ignore' }).status === 0,
) ?? null;
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css' };
// Serves a directory over http://127.0.0.1 so a page can import ES modules from it.
export function startStaticServer(root) {
const base = resolve(root);
const server = createServer((req, res) => {
const path = resolve(join(base, decodeURIComponent(new URL(req.url, 'http://localhost').pathname)));
let isFile = false;
try {
isFile = path.startsWith(base + sep) && statSync(path).isFile();
} catch {
// not found
}
if (!isFile) {
res.writeHead(404).end('not found');
return;
}
res.writeHead(200, { 'content-type': TYPES[extname(path)] ?? 'application/octet-stream' });
res.end(readFileSync(path));
});
return new Promise((done) => {
server.listen(0, '127.0.0.1', () => {
done({ origin: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((closed) => server.close(closed)) });
});
});
}
export async function launchBrowser() {
const profile = mkdtempSync(join(tmpdir(), 'outlier-saver-chrome-'));
const proc = spawn(
CHROME_PATH,
['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', 'about:blank'],
{ stdio: 'ignore' },
);
const portFile = join(profile, 'DevToolsActivePort');
let port = null;
for (let attempt = 0; attempt < 80 && port === null; attempt += 1) {
if (existsSync(portFile)) port = Number(readFileSync(portFile, 'utf8').split('\n')[0]);
else await sleep(250);
}
if (port === null) {
proc.kill();
throw new Error('Chrome did not start');
}
return {
async openPage(url) {
const target = await (await fetch(`http://127.0.0.1:${port}/json/new?${url}`, { method: 'PUT' })).json();
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((opened, failed) => {
ws.onopen = opened;
ws.onerror = failed;
});
let nextId = 0;
const pending = new Map();
ws.onmessage = ({ data }) => {
const message = JSON.parse(data);
const waiter = pending.get(message.id);
if (!waiter) return;
pending.delete(message.id);
if (message.error) waiter.fail(new Error(message.error.message));
else waiter.done(message.result);
};
const send = (method, params = {}) =>
new Promise((done, fail) => {
nextId += 1;
pending.set(nextId, { done, fail });
ws.send(JSON.stringify({ id: nextId, method, params }));
});
const evaluate = async (expression) => {
const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text);
}
return result.result.value;
};
for (let attempt = 0; attempt < 80; attempt += 1) {
if ((await evaluate('document.readyState')) === 'complete') break;
await sleep(100);
}
return { evaluate, close: () => ws.close() };
},
async close() {
proc.kill('SIGTERM');
await sleep(300);
rmSync(profile, { recursive: true, force: true, maxRetries: 3 });
},
};
}
test/fixtures/harness.html:
<!doctype html>
<meta charset="utf-8">
<title>harness</title>
<body></body>
test/fixtures/search-card.html — упрощённая карточка поиска (ytd-video-renderer), собранная по структуре, которую я наблюдал на живом YouTube 2026-10-25; блоки VidIQ взяты по образцу из home-card.html:
<!-- Synthetic: modelled on the search-result card structure observed on youtube.com on 2026-10-25;
the VidIQ blocks copy the structure of home-card.html. -->
<ytd-video-renderer>
<div id="dismissible">
<ytd-thumbnail>
<a id="thumbnail" href="/watch?v=pyV68KvUwlw&pp=ygUSbWluZWNyYWZ0IHNwZWVkcnVu">
<img src="https://i.ytimg.com/vi/pyV68KvUwlw/hq720.jpg?sqp=abc&rs=def">
<div id="overlays"><span id="text" aria-label="1 минута 29 секунд">1:29</span></div>
</a>
</ytd-thumbnail>
<div class="text-wrapper">
<div id="meta">
<div id="title-wrapper">
<h3 class="title-and-badge">
<a id="video-title" href="/watch?v=pyV68KvUwlw&pp=ygUSbWluZWNyYWZ0IHNwZWVkcnVu"
aria-label="Minecraft Beaten In 53 Seconds [FWR] 1 минута 29 секунд" title="Minecraft Beaten In 53 Seconds [FWR]">
<yt-formatted-string>Minecraft Beaten In 53 Seconds [FWR]</yt-formatted-string>
</a>
</h3>
</div>
<ytd-video-meta-block>
<div id="metadata">
<div id="byline-container">
<ytd-channel-name id="channel-name"><div id="container"><div id="text-container">
<yt-formatted-string id="text" title="HanabiYaki"><a href="/@HanabiYaki">HanabiYaki</a></yt-formatted-string>
</div></div></ytd-channel-name>
</div>
<div id="metadata-line">
<span class="inline-metadata-item">5,1 млн</span>
<span class="inline-metadata-item">2 г. назад</span>
<div class="vidiq-scope">
<span data-vidiq-subscriber-count="">111 тыс.</span><span>подписчики</span>
</div>
</div>
</div>
<span class="inline-video-stats vidiq-scope">
<div data-hover-popover="VphPopover"><span>29 VPH</span></div>
<div data-hover-popover="OutlierPopover">
<a href="https://app.vidiq.com/outliers?utm_source=chrome&outlier_score=7.456&video_id=pyV68KvUwlw"><span>7.5x</span></a>
</div>
</span>
</ytd-video-meta-block>
</div>
</div>
</div>
</ytd-video-renderer>
test/fixtures/sidebar-card.html — карточка боковой панели (новый макет, название канала текстом без ссылки, две строки метаданных):
<!-- Synthetic: modelled on the watch-page sidebar lockup observed on youtube.com on 2026-10-25;
the VidIQ blocks copy the structure of home-card.html. -->
<yt-lockup-view-model class="lockup">
<div class="ytLockupViewModelHost content-id-7KXuP7Ws8W8">
<a class="ytLockupViewModelContentImage" href="/watch?v=7KXuP7Ws8W8">
<yt-thumbnail-view-model><div class="ytThumbnailViewModelImage"><img src="https://i.ytimg.com/vi/7KXuP7Ws8W8/hqdefault.jpg"></div></yt-thumbnail-view-model>
</a>
<div class="ytLockupViewModelMetadata">
<yt-lockup-metadata-view-model><div class="ytLockupMetadataViewModelTextContainer">
<h3 class="ytLockupMetadataViewModelHeadingReset" title="CБОРНИК - В Гостях У Бабули 21-30 серии">
<a class="ytLockupMetadataViewModelTitle" href="/watch?v=7KXuP7Ws8W8"><span>CБОРНИК - В Гостях У Бабули 21-30 серии</span></a>
</h3>
<div class="ytLockupMetadataViewModelMetadata">
<yt-content-metadata-view-model>
<div class="ytContentMetadataViewModelMetadataRow">
<span class="ytAttributedStringHost ytContentMetadataViewModelMetadataText">Уродская Анимация</span>
</div>
<div class="ytContentMetadataViewModelMetadataRow">
<span class="ytAttributedStringHost ytContentMetadataViewModelMetadataText" aria-label="1,8 миллиона просмотров">1,8 млн</span>
<span class="ytContentMetadataViewModelDelimiter"></span>
<span class="ytAttributedStringHost ytContentMetadataViewModelMetadataText" aria-label="3 недели назад">3 нед. назад</span>
</div>
<div class="vidiq-scope">
<span data-vidiq-subscriber-count="">2,82 млн</span><span>подписчики</span>
</div>
<span class="inline-video-stats vidiq-scope">
<div data-hover-popover="OutlierPopover">
<a href="https://app.vidiq.com/outliers?outlier_score=12&video_id=7KXuP7Ws8W8"><span>12x</span></a>
</div>
</span>
</yt-content-metadata-view-model>
</div>
</div></yt-lockup-metadata-view-model>
</div>
</div>
</yt-lockup-view-model>
- Step 2: Write the failing test
test/card.dom.test.js:
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');
});
- Step 3: Run the test to verify it fails
Run: TMPDIR=<scratchpad> node --test test/card.dom.test.js
Expected: tests FAIL (the page cannot import /src/lib/card.js: the file does not exist yet).
- Step 4: Write the implementation
src/lib/card.js:
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,
};
}
- Step 5: Run the test to verify it passes
Run: TMPDIR=<scratchpad> node --test test/card.dom.test.js
Expected: PASS, 8 tests (or all skipped with the reason "Chrome is not installed").
If a case fails, fix card.js (not the fixture) unless the fixture contradicts the real markup in home-card.html.
- Step 6: Commit
git add src/lib/card.js tools/browser.js test/card.dom.test.js test/fixtures
git commit -m "feat: scrape video cards and verify against real markup in headless Chrome" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 5: Экспорт, настройки и база под новую модель
Files:
- Modify:
src/lib/export.js(колонки),src/lib/storage.js(толькоthreshold,minViews; безstatus),src/lib/db.js(толькоoutliers) - Modify:
test/export.test.js,test/storage.test.js
Interfaces:
-
COLUMNS=videoId, title, url, thumbnailUrl, channelTitle, channelUrl, subscribers, views, outlierScore, isShort, foundOn, firstSeenAt, updatedAt -
DEFAULT_SETTINGS = { threshold: 5, minViews: 10000 };normalizeSettings(raw?),getSettings(),saveSettings(raw)— безapiKey;getStatusиsetStatusудалены -
db.jsэкспортируетopenDb,getOutliers(ids): Promise<Map>,putOutliers(records),listOutliers(limit?),countOutliers(); функции дляseenиchannelsудалены -
Step 1: Update the tests so they fail
test/export.test.js — заменить файл:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { COLUMNS, toCsv, toJson } from '../src/lib/export.js';
const record = (over = {}) => ({
videoId: 'dQw4w9WgXcQ',
title: 'Hello',
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
thumbnailUrl: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
channelTitle: 'Chan',
channelUrl: 'https://www.youtube.com/@chan',
subscribers: 1000,
views: 50000,
outlierScore: 50,
isShort: false,
foundOn: 'home',
firstSeenAt: '2026-10-25T10:00:00.000Z',
updatedAt: '2026-10-25T10:00:00.000Z',
...over,
});
test('COLUMNS lists the stored fields in order', () => {
assert.deepEqual(COLUMNS, [
'videoId', 'title', 'url', 'thumbnailUrl', 'channelTitle', 'channelUrl', 'subscribers',
'views', 'outlierScore', 'isShort', 'foundOn', 'firstSeenAt', 'updatedAt',
]);
});
test('toCsv writes a header and one row per record', () => {
assert.equal(
toCsv([record()]),
COLUMNS.join(',') +
'\r\n' +
'dQw4w9WgXcQ,Hello,https://www.youtube.com/watch?v=dQw4w9WgXcQ,' +
'https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg,Chan,https://www.youtube.com/@chan,' +
'1000,50000,50,false,home,2026-10-25T10:00:00.000Z,2026-10-25T10:00:00.000Z\r\n',
);
});
test('toCsv quotes commas, quotes and newlines', () => {
const csv = toCsv([record({ title: 'He said "hi", then\nleft' })]);
assert.ok(csv.includes('"He said ""hi"", then\nleft"'));
});
test('toCsv writes null and undefined as empty cells and keeps booleans readable', () => {
const csv = toCsv([record({ subscribers: null, isShort: true, channelUrl: undefined })]);
const row = csv.split('\r\n')[1];
assert.ok(row.includes(',true,'));
assert.ok(row.includes('Chan,,,50000')); // channelUrl and subscribers empty
});
test('toCsv of no records is just the header', () => {
assert.equal(toCsv([]), COLUMNS.join(',') + '\r\n');
});
test('toJson pretty-prints and round-trips', () => {
const records = [record(), record({ videoId: 'abcdefghijk' })];
const text = toJson(records);
assert.ok(text.includes('\n '));
assert.deepEqual(JSON.parse(text), records);
});
test/storage.test.js — заменить файл:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_SETTINGS, normalizeSettings } from '../src/lib/storage.js';
test('normalizeSettings falls back to defaults for missing input', () => {
assert.deepEqual(DEFAULT_SETTINGS, { threshold: 5, minViews: 10000 });
assert.deepEqual(normalizeSettings(), DEFAULT_SETTINGS);
assert.deepEqual(normalizeSettings({}), DEFAULT_SETTINGS);
});
test('normalizeSettings coerces numeric strings', () => {
assert.deepEqual(normalizeSettings({ threshold: '7.5', minViews: '20000' }), { threshold: 7.5, minViews: 20000 });
});
test('normalizeSettings replaces invalid thresholds with the default', () => {
for (const threshold of [-1, 0, 'abc', '', null, NaN, Infinity]) {
assert.equal(normalizeSettings({ threshold }).threshold, 5, String(threshold));
}
});
test('normalizeSettings replaces invalid minViews with the default and floors decimals', () => {
for (const minViews of [-5, 'abc', '', null, NaN]) {
assert.equal(normalizeSettings({ minViews }).minViews, 10000, String(minViews));
}
assert.equal(normalizeSettings({ minViews: 1500.7 }).minViews, 1500);
assert.equal(normalizeSettings({ minViews: 0 }).minViews, 0);
});
test('normalizeSettings drops fields it does not know (the old apiKey)', () => {
assert.deepEqual(normalizeSettings({ apiKey: 'secret', threshold: 6 }), { threshold: 6, minViews: 10000 });
});
- Step 2: Run the tests to verify they fail
Run: node --test test/export.test.js test/storage.test.js
Expected: FAIL (old columns, apiKey still returned).
- Step 3: Update the implementation
src/lib/export.js — заменить массив COLUMNS, остальное не менять:
export const COLUMNS = [
'videoId',
'title',
'url',
'thumbnailUrl',
'channelTitle',
'channelUrl',
'subscribers',
'views',
'outlierScore',
'isShort',
'foundOn',
'firstSeenAt',
'updatedAt',
];
src/lib/storage.js — заменить файл:
export const DEFAULT_SETTINGS = { threshold: 5, minViews: 10000 };
const toNumber = (value) => (value === null || value === undefined || value === '' ? NaN : Number(value));
export function normalizeSettings(raw = {}) {
const threshold = toNumber(raw.threshold);
const minViews = toNumber(raw.minViews);
return {
threshold: Number.isFinite(threshold) && threshold > 0 ? threshold : DEFAULT_SETTINGS.threshold,
minViews: Number.isFinite(minViews) && minViews >= 0 ? Math.floor(minViews) : DEFAULT_SETTINGS.minViews,
};
}
export async function getSettings() {
const { settings } = await chrome.storage.local.get('settings');
return normalizeSettings(settings);
}
export async function saveSettings(raw) {
const settings = normalizeSettings(raw);
await chrome.storage.local.set({ settings });
return settings;
}
src/lib/db.js — заменить файл:
const DB_NAME = 'outlier-saver';
const DB_VERSION = 1;
let dbPromise = null;
export function openDb() {
dbPromise ??= new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
request.result.createObjectStore('outliers', { keyPath: 'videoId' }).createIndex('updatedAt', 'updatedAt');
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
}).catch((err) => {
dbPromise = null;
throw err;
});
return dbPromise;
}
function promisify(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function getOutliers(videoIds) {
const db = await openDb();
const store = db.transaction('outliers', 'readonly').objectStore('outliers');
const records = await Promise.all(videoIds.map((id) => promisify(store.get(id))));
return new Map(records.filter(Boolean).map((record) => [record.videoId, record]));
}
export async function putOutliers(records) {
if (records.length === 0) return;
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction('outliers', 'readwrite');
const store = tx.objectStore('outliers');
for (const record of records) store.put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
// Newest `updatedAt` first.
export async function listOutliers(limit = Infinity) {
const db = await openDb();
return new Promise((resolve, reject) => {
const results = [];
const request = db.transaction('outliers', 'readonly').objectStore('outliers').index('updatedAt').openCursor(null, 'prev');
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || results.length >= limit) {
resolve(results);
return;
}
results.push(cursor.value);
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
export async function countOutliers() {
const db = await openDb();
return promisify(db.transaction('outliers', 'readonly').objectStore('outliers').count());
}
- Step 4: Run the tests and a syntax check
Run: node --test test/export.test.js test/storage.test.js && node --check src/lib/db.js
Expected: PASS (export 6 tests, storage 5 tests), no syntax errors.
- Step 5: Commit
git add src/lib/export.js src/lib/storage.js src/lib/db.js test/export.test.js test/storage.test.js
git commit -m "refactor: align export, settings and database with the VidIQ record model" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 6: Service worker, манифест и удаление слоя API
Files:
- Modify (rewrite):
src/background.js,manifest.json - Delete:
src/lib/api.js,src/lib/process.js,src/lib/score.js,test/api-helpers.test.js,test/api-requests.test.js,test/process.test.js,test/score.test.js
Interfaces:
-
Consumes:
sanitizeItem,buildRecord(record.js);getSettings(storage.js);getOutliers,putOutliers(db.js). -
Produces: обработчик сообщений
{ type: 'cards', items: Item[] }(до 200 карточек за сообщение); сообщения обрабатываются строго по очереди. -
Step 1: Write
src/background.js
import { getOutliers, putOutliers } from './lib/db.js';
import { buildRecord, sanitizeItem } from './lib/record.js';
import { getSettings } from './lib/storage.js';
const MAX_ITEMS_PER_MESSAGE = 200;
async function store(rawItems) {
const items = rawItems.slice(0, MAX_ITEMS_PER_MESSAGE).map(sanitizeItem).filter(Boolean);
if (items.length === 0) return;
const [settings, existing] = await Promise.all([getSettings(), getOutliers(items.map((item) => item.videoId))]);
const now = new Date();
const records = items.map((item) => buildRecord(item, existing.get(item.videoId), settings, now)).filter(Boolean);
await putOutliers(records);
}
// Messages are handled one after another so two batches never read-modify-write the same video at once.
let queue = Promise.resolve();
chrome.runtime.onMessage.addListener((message) => {
if (message?.type !== 'cards' || !Array.isArray(message.items)) return;
queue = queue.then(() => store(message.items)).catch((err) => console.warn('Outlier Saver: failed to store cards', err));
});
- Step 2: Write
manifest.json(пока без content script, popup и options остаются как были)
{
"manifest_version": 3,
"name": "Outlier Saver",
"version": "0.2.0",
"description": "Сохраняет видео YouTube с высоким outlier score от VidIQ.",
"permissions": ["storage"],
"background": { "service_worker": "src/background.js", "type": "module" },
"action": { "default_title": "Outlier Saver", "default_popup": "src/popup/popup.html" },
"options_ui": { "page": "src/options/options.html", "open_in_tab": true },
"content_scripts": [
{
"matches": ["https://www.youtube.com/*"],
"js": ["src/content.js"],
"run_at": "document_idle"
}
],
"web_accessible_resources": [
{
"resources": ["src/lib/*.js"],
"matches": ["https://www.youtube.com/*"]
}
]
}
- Step 3: Delete the API layer and its tests
Run: git rm src/lib/api.js src/lib/process.js src/lib/score.js test/api-helpers.test.js test/api-requests.test.js test/process.test.js test/score.test.js
- Step 4: Verify
Run: node --check src/background.js && python3 -c "import json;json.load(open('manifest.json'))" && npm test
Expected: syntax OK, manifest valid JSON, all remaining tests pass (video-id, numbers, vidiq, record, export, storage, card.dom).
- Step 5: Commit
git add manifest.json src/background.js
git commit -m "refactor: store VidIQ cards in the service worker and drop the API layer" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 7: Content script
Files:
- Modify (rewrite):
src/content.js
Interfaces:
- Consumes:
pageFromPath(video-id.js);CARD_SELECTOR,LINK_SELECTOR,scrapeCard(card.js) — оба загружаются через динамическийimport()(content script не может быть ES-модулем), поэтомуsrc/lib/*.jsперечислены вweb_accessible_resources. - Produces: сообщения
{ type: 'cards', items: Item[] }.
Поведение: сканирует не чаще раза в 500 мс (троттлинг); пропускает карточки без outlierScore или views; повторно отправляет карточку, только если изменилась подпись outlierScore|views|subscribers; после yt-navigate-finish сбрасывает подписи и сканирует заново; на странице канала берёт канал из URL и заголовка вкладки; отключается, если расширение перезагрузили.
- Step 1: Write
src/content.js
(async () => {
const { pageFromPath } = await import(chrome.runtime.getURL('src/lib/video-id.js'));
const { CARD_SELECTOR, LINK_SELECTOR, scrapeCard } = await import(chrome.runtime.getURL('src/lib/card.js'));
const SCAN_INTERVAL_MS = 500;
const MAX_ITEMS_PER_MESSAGE = 200;
// videoId -> "outlierScore|views|subscribers" of the last message that carried the video.
// VidIQ may draw or update its numbers after the card appears, so a changed signature is sent again.
let sent = new Map();
let scanTimer = null;
// Cards on a channel page do not name the channel, so take it from the page itself.
function channelContext() {
const [kind, name] = location.pathname.split('/').filter(Boolean);
const path = kind?.startsWith('@') ? kind : ['channel', 'c', 'user'].includes(kind) && name ? `${kind}/${name}` : null;
return {
channelUrl: path ? `https://www.youtube.com/${path}` : null,
channelTitle: document.title.replace(/^\(\d+\)\s*/, '').replace(/\s*-\s*YouTube$/, '') || null,
};
}
function scan() {
scanTimer = null;
const page = pageFromPath(location.pathname);
if (!page) return;
const context = page === 'channel' ? channelContext() : null;
const found = new Map();
const visited = new Set();
for (const link of document.querySelectorAll(LINK_SELECTOR)) {
const card = link.closest(CARD_SELECTOR);
if (!card || visited.has(card)) continue;
visited.add(card);
const item = scrapeCard(card, link, context);
if (!item || item.outlierScore === null || item.views === null) continue;
const signature = `${item.outlierScore}|${item.views}|${item.subscribers}`;
if (sent.get(item.videoId) === signature) continue;
found.set(item.videoId, { item: { ...item, page }, signature });
}
if (found.size === 0) return;
const batch = [...found.values()].slice(0, MAX_ITEMS_PER_MESSAGE);
for (const { item, signature } of batch) sent.set(item.videoId, signature);
try {
chrome.runtime.sendMessage({ type: 'cards', items: batch.map(({ item }) => item) }).catch(() => {});
} catch {
observer.disconnect(); // the extension was reloaded; this script is orphaned
return;
}
if (found.size > batch.length) scheduleScan();
}
function scheduleScan() {
if (scanTimer === null) scanTimer = setTimeout(scan, SCAN_INTERVAL_MS);
}
const observer = new MutationObserver(scheduleScan);
observer.observe(document.documentElement, { childList: true, subtree: true });
document.addEventListener('yt-navigate-finish', () => {
sent = new Map();
scheduleScan();
});
scheduleScan();
})();
- Step 2: Verify
Run: node --check src/content.js && npm test
Expected: no syntax errors, all tests pass. Работу в браузере проверит Task 9.
- Step 3: Commit
git add src/content.js
git commit -m "feat: scan video cards and send VidIQ-scored ones to the service worker" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 8: Настройки и popup без ключа и статуса
Files:
- Modify:
src/options/options.html,src/options/options.js - Modify:
src/popup/popup.html,src/popup/popup.css,src/popup/popup.js
Interfaces:
-
Consumes:
getSettings,saveSettings(storage.js);countOutliers,listOutliers(db.js);toCsv,toJson(export.js). -
Step 1: Options page
В src/options/options.html удалить <label> с apiKey и весь блок <details>…</details>; подписи оставшихся полей:
<label>
Порог outlier score VidIQ (сохранять видео от этого значения)
<input id="threshold" type="number" min="0.1" step="0.1" required>
</label>
<label>
Минимум просмотров
<input id="minViews" type="number" min="0" step="1000" required>
</label>
src/options/options.js — заменить файл:
import { getSettings, saveSettings } from '../lib/storage.js';
const form = document.getElementById('form');
const threshold = document.getElementById('threshold');
const minViews = document.getElementById('minViews');
const savedLabel = document.getElementById('saved');
function fill(settings) {
threshold.value = settings.threshold;
minViews.value = settings.minViews;
}
fill(await getSettings());
form.addEventListener('submit', async (event) => {
event.preventDefault();
fill(await saveSettings({ threshold: threshold.value, minViews: minViews.value }));
savedLabel.textContent = 'Сохранено';
setTimeout(() => {
savedLabel.textContent = '';
}, 2000);
});
- Step 2: Popup
src/popup/popup.html: удалить строку <p id="status" role="status"></p>.
src/popup/popup.css: удалить два правила #status { … } и #status.warn { … }.
src/popup/popup.js:
- удалить импорт
getStatus, функциюstatusText, константуstatusEl, а в начале запросов заменитьPromise.all([countOutliers(), listOutliers(LIST_LIMIT), getStatus()])наPromise.all([countOutliers(), listOutliers(LIST_LIMIT)])с деструктуризацией[count, recent]; - удалить три строки, которые ставили текст и класс статуса (
const { text, warn } = statusText(status),statusEl.textContent = text,statusEl.classList.toggle(...)); - в
renderRowзаменить блок значков на один:
const badges = document.createElement('div');
badges.append(badge(`${outlier.outlierScore}x`));
-
текст пустого состояния:
'Пока ничего не найдено. Откройте YouTube с включённым VidIQ и пролистайте ленту.'. -
Step 3: Verify
Run: node --check src/options/options.js && node --check src/popup/popup.js && grep -n "apiKey\|getStatus\|statusEl\|vidiqScore" -r src/ || echo "no leftovers"
Expected: syntax OK and no leftovers.
- Step 4: Commit
git add src/options src/popup
git commit -m "refactor: remove API key and status from options and popup" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 9: README и сквозная проверка в Chrome
Files:
-
Modify:
README.md -
Scratch (не коммитится): сквозной скрипт в scratchpad
-
Step 1: Update
README.md
Заменить файл:
# Outlier Saver
Расширение для Chrome (личное): пока вы смотрите YouTube с установленным VidIQ, оно читает карточки видео и сохраняет те, у которых высокий outlier score. Данные можно выгрузить в JSON или CSV.
## Установка
1. Откройте `chrome://extensions` и включите «Режим разработчика».
2. «Загрузить распакованное расширение» → выберите эту папку.
3. Откройте YouTube (VidIQ должен быть включён) и пролистайте ленту, поиск или страницу канала.
## Как это работает
- Расширение ничего не скачивает и не использует API: оно читает то, что уже нарисовано на странице.
- Со страницы берутся название, ссылка, превью, канал и просмотры; у VidIQ — outlier score и число подписчиков. Просмотры и подписчики те же, что на экране (например, «59 млн»), поэтому округлены.
- Видео сохраняется, если `outlier score ≥ порога` и `просмотры ≥ минимума` (по умолчанию 5 и 10 000, меняются в настройках). Уже сохранённое видео обновляется при каждой встрече.
- Данные лежат в IndexedDB браузера. Кнопки «Скачать JSON» и «Скачать CSV» в popup выгружают всё сохранённое.
- Поддерживаются русский и английский интерфейс YouTube.
## Если ничего не сохраняется
- Убедитесь, что VidIQ показывает на карточках outlier score и подписчиков.
- Понизьте порог или минимум просмотров в настройках.
- Если YouTube или VidIQ изменили разметку, запустите `npm test`: тест `card.dom` разберёт образцы карточек в Chrome и покажет, что именно не читается. Образец с главной лежит в `test/fixtures/home-card.html`.
## Разработка
Сборки нет. Тесты (Node 22, без зависимостей; тест разбора карточки запускает headless Chrome и пропускается, если Chrome не найден):
```
npm test
```
После изменения кода нажмите «Обновить» у расширения на `chrome://extensions`.
- Step 2: Full test run
Run: TMPDIR=<scratchpad> npm test
Expected: все тесты проходят (в том числе card.dom).
- Step 3: End-to-end check in headless Chrome (scratch script)
Скрипт в scratchpad:
- Запускает Chrome с
--enable-unsafe-extension-debugging(без--load-extension) и загружает расширение через CDPExtensions.loadUnpacked. - Открывает живую страницу
https://www.youtube.com/results?search_query=minecraft+speedrunи после загрузки подмешивает в каждую карточкуytd-video-rendererблок, повторяющий разметку VidIQ изtest/fixtures/search-card.html([data-hover-popover="OutlierPopover"] a[href*="outlier_score=…"]и[data-vidiq-subscriber-count]) с разными значениями score (часть ниже порога). - Проверяет через страницу расширения
popup.html, что в IndexedDB лежат только карточки сoutlierScore ≥ 5иviews ≥ 10000, что у записей заполнены название,url,thumbnailUrl,channelTitle,channelUrl,subscribers,views,outlierScore,foundOn: "search", и что popup показывает их без разметки из названий. - Меняет значение score у одной карточки и проверяет, что запись обновилась, а
firstSeenAtостался прежним. - Прокручивает страницу, подмешивает блоки в новые карточки и проверяет, что число записей выросло.
- Проверяет настройки: сохраняет порог 1000 и убеждается, что новые карточки с меньшим score не сохраняются; страница
options.htmlне содержит поля ключа.
Expected: все проверки проходят.
- Step 4: Commit
git add README.md
git commit -m "docs: describe the VidIQ-based workflow" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 10 (добавлена после проверки в браузере): один обычный файл для content script
Почему: в chrome://extensions у расширения копились ошибки «Unsafe attempt to load URL chrome-extension://…/src/lib/video-id.js from frame with URL https://www.youtube.com/…». Разбор показал: Chrome пишет это сообщение при динамическом import() модулей из content script (недетерминированно, воспроизводится даже на минимальном примере из двух файлов), хотя запросы завершаются статусом 200 и расширение работает. Чтобы не копить ошибки, content script собирается в один обычный файл без import().
Files:
- Create:
tools/build-content.js(функцияbundle(entry)и запускnpm run build),src/content/main.js(исходник content script на статическихimport),test/bundle.test.js - Generate:
src/content.js(npm run build, коммитится) - Modify:
manifest.json(убратьweb_accessible_resources),package.json(скриптbuild),README.md, спецификация
Что проверено:
test/bundle.test.js(6 тестов): порядок модулей, общий модуль включён один раз, отказ при совпадающих именах и неподдерживаемомexport, закоммиченныйsrc/content.jsсовпадает со свежей сборкой, в нём нетimport(), в манифесте нетweb_accessible_resources.- Три прогона воспроизведения на главной, поиске, просмотре и канале: сообщений «Unsafe attempt» нет (раньше не меньше четырёх на страницу).
- Сквозная проверка в Chrome (26 проверок) проходит на собранном файле.
Правило разработки: после правки src/content/main.js или модулей из src/lib/, которые он использует, выполните npm run build; без этого тест bundle.test.js падает.