# Outlier Saver — дизайн расширения (версия 2: данные VidIQ) Дата: 2026-10-25. Версия 1 (YouTube Data API и свой расчёт score) снята: API недоступен в регионе пользователя. Предыдущая версия лежит в истории git (коммит `05d8bff`). ## Цель Расширение для Chrome (Manifest V3) для личного использования. Пока пользователь смотрит YouTube с установленным VidIQ, оно читает карточки видео, берёт у VidIQ outlier score и число подписчиков и сохраняет видео с высоким score локально. Позже данные выгружаются в JSON или CSV. ## Принятые решения | Вопрос | Решение | |---|---| | Источник данных | Только DOM страницы YouTube. Никаких запросов к сети, API-ключей и host-разрешений | | Outlier score | Берётся у VidIQ, своя формула удалена | | Подписчики | Берутся у VidIQ из карточки (округлены, как на экране) | | Просмотры | Берутся из разметки YouTube (округлены, как на экране) | | Порог | Сохраняем при `outlierScore ≥ threshold` и `views ≥ minViews` (по умолчанию 5 и 10 000, меняются в настройках) | | Хранение | IndexedDB в браузере, экспорт в JSON и CSV из popup | | Страницы | Главная, страница просмотра (боковые карточки), поиск, страница канала | | Языки интерфейса | Русский и английский | | Сборка | Только для content script: `npm run build` склеивает `src/content/main.js` и его модули в один обычный файл `src/content.js` (он закоммичен, тест следит, чтобы он не отставал). Причина: динамический `import()` в content script оставляет в `chrome://extensions` ошибки «Unsafe attempt to load URL chrome-extension://…», хотя модули грузятся. Остальной код без сборки: чистый JS, ES-модули, загрузка как «распакованное расширение» | ## Откуда берётся каждое поле Опорные точки взяты из реальной разметки карточки с главной (образец лежит в `test/fixtures/home-card.html`). Привязка идёт к атрибутам и ссылкам VidIQ, а не к его сгенерированным классам вида `vidiq-c-bXbWpx`. | Поле | Источник | |---|---| | `videoId`, `isShort` | Ссылка `/watch?v=` или `/shorts/` внутри карточки | | `title` | `h3[title]`, иначе `#video-title`, иначе `.ytLockupMetadataViewModelTitle` | | `thumbnailUrl` | `img` с `src` на `https://i.ytimg.com/vi/…`, иначе `https://i.ytimg.com/vi//mqdefault.jpg` | | `channelTitle`, `channelUrl` | Первая ссылка `/@…`, `/channel/…`, `/c/…`, `/user/…` с текстом, вне блоков VidIQ. Если ссылки нет (боковая панель), то текст канала из строки метаданных. На странице канала — из URL страницы и заголовка вкладки | | `views` | Первый элемент метаданных, текст которого целиком разбирается как число с единицей («59 млн», «5,1 млн», «123 тыс.», «1.2M»), вне блоков VidIQ | | `outlierScore` | Параметр `outlier_score` в ссылке внутри `[data-hover-popover="OutlierPopover"]` (точное значение, например 146.19). Если параметра нет, то число из текста значка (`>100x` → 100) | | `subscribers` | Текст `[data-vidiq-subscriber-count]` («571 тыс.» → 571000), `null`, если элемента нет | ## Архитектура | Часть | Файл | Ответственность | |---|---|---| | Content script | `src/content/main.js` → `src/content.js` | Наблюдает за DOM на `youtube.com`, разбирает карточки, отправляет в background только карточки, где VidIQ уже вывел score, и только когда данные изменились. В манифесте подключён собранный `src/content.js`, исходник — `src/content/main.js` | | Сборка content script | `tools/build-content.js` | Склеивает исходник и импортируемые модули в один обычный скрипт (поддерживает только однострочные `import` и `export` перед `const/let/function/class`, имена верхнего уровня должны быть уникальны) | | Service worker | `src/background.js` | Принимает карточки, подтягивает настройки и ранее сохранённые записи, пишет результат в IndexedDB. Записи обрабатываются строго по очереди | | Разбор карточки | `src/lib/card.js` | `scrapeCard(card, link, context)`: достаёт поля из DOM-элемента карточки | | Данные VidIQ | `src/lib/vidiq.js` | Селекторы VidIQ, `outlierScoreFromHref`, `parseVidiqScore`, `readVidiqData` | | Числа | `src/lib/numbers.js` | `parseCount`, `parseViews`: «59 млн», «571 тыс.», «1.2M», «12 345» → число | | Ссылки | `src/lib/video-id.js` | `isVideoId`, `parseVideoLink`, `pageFromPath` | | Запись | `src/lib/record.js` | `shouldSave`, `sanitizeItem` (проверка входящего сообщения), `buildRecord` (создание и обновление записи) | | Настройки | `src/lib/storage.js` | Значения по умолчанию, `normalizeSettings`, чтение и запись настроек | | База | `src/lib/db.js` | Обёртка над IndexedDB, одно хранилище `outliers` | | Экспорт | `src/lib/export.js` | JSON и CSV | | Popup | `src/popup/` | Счётчик, список последних находок, кнопки экспорта | | Options | `src/options/` | Порог score и минимум просмотров | ## Поток данных 1. Content script запускает сканирование не чаще раза в 500 мс: после изменения DOM (`MutationObserver`) и после события `yt-navigate-finish` (YouTube — SPA). Это троттлинг, а не дебаунс: пока играет видео, DOM меняется постоянно. После навигации список уже отправленного сбрасывается. 2. Для каждой ссылки `/watch?v=` и `/shorts/` ищется ближайший элемент карточки, и `scrapeCard` читает поля. Карточка пропускается, если нет `outlierScore` (VidIQ ещё не отрисовал блок: она подхватится при следующем сканировании) или нет `views`. 3. Для каждой карточки считается подпись `outlierScore|views|subscribers`. Карточка отправляется, только если подпись изменилась с прошлой отправки: VidIQ может дорисовать или обновить числа позже. 4. Background проверяет сообщение (`sanitizeItem`), читает настройки и существующие записи, для каждой карточки вызывает `buildRecord`, и записывает результат в IndexedDB. 5. `buildRecord`: новое видео сохраняется, если выполнено условие порога. Уже сохранённое видео обновляется всегда, даже если его score упал ниже порога. ## Модель данных Хранилище `outliers` (ключ — `videoId`, индекс по `updatedAt`): | Поле | Описание | |---|---| | `videoId` | ID видео | | `title` | Название (не длиннее 300 символов) | | `url` | `https://www.youtube.com/watch?v=` для всех видео, включая Shorts | | `thumbnailUrl` | Ссылка на превью | | `channelTitle`, `channelUrl` | Канал (`null`, если не удалось определить) | | `subscribers` | Число подписчиков от VidIQ или `null` | | `views` | Просмотры на момент последней проверки | | `outlierScore` | Outlier score от VidIQ, до двух знаков | | `isShort` | `true`, если видео хотя бы раз встретилось по ссылке `/shorts/` | | `foundOn` | Страница первой находки: `home`, `watch`, `search` или `channel` | | `firstSeenAt`, `updatedAt` | Временные метки (ISO). `firstSeenAt` не меняется при обновлении | CSV содержит эти же поля в том же порядке. ## Настройки (`chrome.storage.local`, ключ `settings`) | Поле | По умолчанию | |---|---| | `threshold` | 5 | | `minViews` | 10 000 | ## Граничные случаи | Ситуация | Поведение | |---|---| | VidIQ не установлен или ещё не отрисовал блок | Карточка пропускается и проверяется при следующем сканировании. Если VidIQ нет совсем, ничего не сохраняется, popup подсказывает про VidIQ | | Не удалось разобрать просмотры (другой язык, прямой эфир) | Карточка пропускается | | VidIQ скрыл подписчиков | `subscribers: null`, видео сохраняется по score | | Повторная встреча видео | Обновляются `views`, `outlierScore`, `subscribers`, `updatedAt`. `firstSeenAt` и `foundOn` не меняются, `isShort` может только стать `true` | | Расширение перезагружено | Старый content script отключается сам | | Сообщение с некорректными данными | Отбрасывается в `sanitizeItem`: проверяются ID, числа, ссылки (`youtube.com`, `i.ytimg.com`) | | YouTube или VidIQ поменяли разметку | Тест на фикстурах в реальном Chrome покажет, что именно перестало разбираться | ## Тестирование - **Автотесты** на встроенном `node:test`, без зависимостей: `numbers.js`, `vidiq.js` (на подставных объектах), `record.js`, `video-id.js`, `export.js`, `storage.js`. - **Тест сборки** (`test/bundle.test.js`): сборщик на временных файлах, проверка, что закоммиченный `src/content.js` совпадает со свежей сборкой, что в нём нет `import()` и что в манифесте нет `web_accessible_resources`. - **Тест разбора карточки в Chrome** (`test/card.dom.test.js`): запускает headless Chrome, загружает фикстуры и проверяет `scrapeCard`. Фикстуры: настоящая карточка с VidIQ с главной, плюс упрощённые карточки поиска и боковой панели. Если Chrome не найден, тест пропускается. - **Сквозная проверка в Chrome** (не входит в репозиторий): живой YouTube с подмешанным в карточки блоком VidIQ, проход через content script, background, IndexedDB и popup. ## Вне объёма (YAGNI) - Свой расчёт score, обращения к YouTube Data API и любым другим сетевым источникам - Открытое видео на странице просмотра (собираются только карточки) - Языки интерфейса кроме русского и английского - Точные просмотры и подписчики (берутся значения с экрана) - SQLite, синхронизация между устройствами, публикация в Chrome Web Store