Files
outlier-saver-extension/docs/superpowers/specs/2026-10-25-outlier-saver-design.md
T
IgorVolochayandClaude Sonnet 5.5 b137776381 fix: bundle the content script to stop 'Unsafe attempt to load URL' errors
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>
2026-10-25 12:25:06 +03:00

13 KiB
Raw Blame History

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=<id> или /shorts/<id> внутри карточки
title h3[title], иначе #video-title, иначе .ytLockupMetadataViewModelTitle
thumbnailUrl img с src на https://i.ytimg.com/vi/…, иначе https://i.ytimg.com/vi/<id>/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=<videoId> для всех видео, включая 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