Files
outlier-saver-extension/docs/superpowers/plans/2026-10-25-vidiq-rework.md
T

70 KiB
Raw Blame History

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: parseCount from ./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: isVideoId from ./video-id.js.

  • Produces:

    • shouldSave({ views, outlierScore }, { threshold, minViews }): boolean
    • sanitizeItem(raw: unknown): Item | null — Item как в разделе «Структура файлов»; возвращает null, если нет корректных videoId, outlierScore (число ≥ 0) или views (число ≥ 0); невалидные необязательные поля становятся null
    • buildRecord(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; поля, которые не удалось прочитать, равны null
    • tools/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&amp;pp=ygUSbWluZWNyYWZ0IHNwZWVkcnVu">
        <img src="https://i.ytimg.com/vi/pyV68KvUwlw/hq720.jpg?sqp=abc&amp;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&amp;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&nbsp;млн</span>
              <span class="inline-metadata-item">2 г. назад</span>
              <div class="vidiq-scope">
                <span data-vidiq-subscriber-count="">111&nbsp;тыс.</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&amp;outlier_score=7.456&amp;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&nbsp;млн</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&nbsp;млн</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&amp;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:

  1. Запускает Chrome с --enable-unsafe-extension-debugging (без --load-extension) и загружает расширение через CDP Extensions.loadUnpacked.
  2. Открывает живую страницу 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 (часть ниже порога).
  3. Проверяет через страницу расширения popup.html, что в IndexedDB лежат только карточки с outlierScore ≥ 5 и views ≥ 10000, что у записей заполнены название, url, thumbnailUrl, channelTitle, channelUrl, subscribers, views, outlierScore, foundOn: "search", и что popup показывает их без разметки из названий.
  4. Меняет значение score у одной карточки и проверяет, что запись обновилась, а firstSeenAt остался прежним.
  5. Прокручивает страницу, подмешивает блоки в новые карточки и проверяет, что число записей выросло.
  6. Проверяет настройки: сохраняет порог 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>"