Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
13 KiB
Outlier Saver — дизайн расширения
Дата: 2026-10-25
Цель
Расширение для Chrome (Manifest V3), которое работает, пока пользователь просто смотрит YouTube: само находит видео с аномально высокими просмотрами относительно размера канала («outliers») и сохраняет их локально. Позже данные выгружаются в JSON или CSV.
Принятые решения
| Вопрос | Решение |
|---|---|
| Источник outlier score | Гибрид: свой расчёт + значок VidIQ, если он есть на карточке |
| Хранение | IndexedDB в браузере, экспорт в JSON и CSV из popup (SQLite не используем) |
| Формула | score = просмотры ÷ подписчики |
| Страницы | Главная (лента), страница просмотра, поиск, страница канала |
| Источник цифр | YouTube Data API v3 (нужен ключ пользователя) |
| Сборка | Нет. Чистый JS, ES-модули, загрузка как «распакованное расширение» |
Архитектура
| Часть | Файл | Ответственность |
|---|---|---|
| Content script | src/content.js |
Следит за DOM на youtube.com, достаёт ID видео из ссылок, читает значок VidIQ, шлёт пачки в background |
| Service worker | src/background.js |
Очередь ID, цикл сброса пачками, статус и бейдж на иконке, реакция на смену ключа |
| Обработка пачки | src/lib/process.js |
processBatch: проверка seen, запросы к API, расчёт score, решение «сохранять или нет», запись в БД. Зависимости (БД и API) передаются параметрами, поэтому логика покрыта тестами |
| Настройки и статус | src/lib/storage.js |
Значения по умолчанию, normalizeSettings, чтение и запись настроек и статуса в chrome.storage.local |
| Формула и решение | src/lib/score.js |
Чистые функции: computeScore, shouldSave |
| Разбор ссылок | src/lib/video-id.js |
Извлечение ID из /watch?v=, /shorts/, отсев мусора, определение типа страницы по пути |
| Значок VidIQ | src/lib/vidiq.js |
Селектор значка (одна константа) и parseVidiqScore(text) |
| API | src/lib/api.js |
videos.list, channels.list, разбиение на пачки по 50, разбор ошибок |
| База | src/lib/db.js |
Обёртка над IndexedDB |
| Экспорт | src/lib/export.js |
Сериализация в JSON и CSV |
| Popup | src/popup/ |
Счётчик, список последних находок, кнопки экспорта, статус сбора |
| Options | src/options/ |
API-ключ, порог score, минимум просмотров |
Ключ API хранится в chrome.storage.local и читается только в background и на странице настроек. Content script его не использует. Запросы к API идут из background, а не со страницы, поэтому CORS не мешает.
Поток данных
- Content script на
youtube.comреагирует на событиеyt-navigate-finish(YouTube — SPA, страницы не перезагружаются) и наMutationObserverпо документу. Сканирование запускается не чаще раза в 500 мс (троттлинг, а не дебаунс: пока играет видео, DOM меняется постоянно, и дебаунс не сработал бы). После навигации список уже отправленных ID сбрасывается. - Из ссылок
a[href*="/watch?v="]иa[href*="/shorts/"]черезvideo-id.jsберутся ID. Для каждой карточки пробуется прочитать значок VidIQ. Уже отправленные с этой страницы ID запоминаются вSet, чтобы не спамить сообщениями. - Content script шлёт background сообщение
{videoId, vidiqScore | null, viaShortsUrl, page}, гдеpage—home | watch | search | channel. - Background копит ID и сбрасывает очередь раз в секунду либо при накоплении 50 штук. ID, проверенные менее 24 часов назад (
seen.checkedAt), пропускаются, кроме ID со значком VidIQ: значок может отрисоваться позже первого сканирования, поэтому content script отправляет ID повторно, когда у него появился score, а проверкаseenдля таких ID не применяется. videos.list(part=snippet,statistics,contentDetails) возвращает название, превью, просмотры, дату публикации, длительность иchannelId.- Для каналов, которых нет в кэше
channelsили чья запись старше 24 часов, вызываетсяchannels.list(part=statistics,snippet). score = views / max(subscribers, 1). Если подписчики скрыты,score = null.- Решение о сохранении (
shouldSave): видео сохраняется, еслиviews ≥ minViewsиscore ≥ threshold, или еслиvidiqScoreнеnull. Условие по VidIQ действует независимо от своего порога и отminViews. Уже сохранённое видео обновляется при каждой проверке, даже если его score опустился ниже порога. - Результат пишется upsert-ом в
outliers, ID с временем проверки — вseen(в том числе несохранённые, чтобы не запрашивать их повторно).
Модель данных
Хранилище outliers (ключ — videoId)
| Поле | Описание |
|---|---|
videoId |
ID видео |
title |
Название |
url |
https://www.youtube.com/watch?v=<videoId> для всех видео, включая Shorts |
thumbnailUrl |
Лучшее доступное превью в порядке maxres → standard → high → medium → default |
channelId, channelTitle, channelUrl |
Канал |
subscribers |
Число подписчиков или null, если скрыто. API округляет до трёх значащих цифр |
views |
Просмотры на момент последней проверки |
publishedAt |
ISO-дата публикации |
durationSec |
Длительность |
isShort |
true, если хотя бы раз видео встретилось по ссылке /shorts/ |
score |
Свой score, округлённый до двух знаков, или null |
vidiqScore |
Число со значка VidIQ или null |
foundOn |
Страница первой находки |
firstSeenAt, updatedAt |
Временные метки (ISO). firstSeenAt не меняется при upsert |
Хранилище channels (ключ — channelId)
channelId, title, subscribers (число или null), fetchedAt.
Хранилище seen (ключ — videoId)
videoId, checkedAt, status (ok или unavailable). После 24 часов запись считается устаревшей и видео запрашивается заново. Это нужно, чтобы видео, которое стало outlier позже, не потерялось, а просмотры у сохранённых обновлялись.
Настройки (chrome.storage.local)
| Ключ | По умолчанию |
|---|---|
apiKey |
пусто |
threshold |
5 |
minViews |
10 000 |
Ошибки и граничные случаи
| Ситуация | Поведение |
|---|---|
| Ключ API не задан | Сбор приостановлен, на иконке бейдж «!», popup ведёт в настройки. В seen ничего не пишется, после ввода ключа видео подхватятся |
Квота исчерпана (403 quotaExceeded) |
Пауза до сброса квоты (полночь по Тихоокеанскому времени), popup показывает причину. Необработанные ID в seen не попадают |
Невалидный ключ (400 или 403 keyInvalid) |
Как при отсутствии ключа, с сообщением об ошибке ключа |
| Сеть или 5xx | Три повтора с нарастающей задержкой, затем ID пропускается без записи в seen |
| Скрытые подписчики | score = null, видео сохраняется только при наличии vidiqScore |
| Очень мало подписчиков | Делитель max(subs, 1), мусор отсекается через minViews |
| Приватное или удалённое видео | API его не возвращает; в seen пишется status: unavailable, повторная проверка после TTL |
| Service worker выгрузили | Очередь живёт в памяти и может потеряться. seen пишется только после результата, а content script присылает ID снова после следующей навигации по YouTube или перезагрузки страницы |
| Повторная встреча видео | Upsert: обновляются views, subscribers, score, vidiqScore (если он есть сейчас), updatedAt; isShort может только стать true |
Расход квоты
videos.list и channels.list стоят по 1 единице за запрос на 50 ID при лимите 10 000 единиц в сутки. Квота не должна быть проблемой при обычном просмотре.
Значок VidIQ: известное ограничение
Точная разметка значка VidIQ на карточке неизвестна, а доступа к живой странице с установленным VidIQ у разработки нет. Поэтому:
- селектор и разбор текста изолированы в
src/lib/vidiq.js; - пока селектор не заполнен,
vidiqScoreвсегдаnull, остальная логика работает без изменений; - последним шагом реализации пользователь показывает DOM карточки со значком VidIQ (правый клик → «Просмотреть код»), после чего селектор и
parseVidiqScoreзаполняются и покрываются тестом на реальном образце текста.
Тестирование
- Автотесты на встроенном
node:test, без зависимостей:score.js(формула,shouldSaveна границах порогов и со скрытыми подписчиками),video-id.js(watch, shorts, мусорные ссылки),api.js(разбиение на пачки по 50, разбор ответов и ошибок на подставных данных),export.js(экранирование запятых, кавычек и переносов строк в названиях). - Ручной чек-лист на живом YouTube: подгрузка карточек при скролле на главной; переход между страницами без перезагрузки; работа на
/resultsи/@channel/videos; запись появляется в popup; скачивание JSON и CSV; поведение без ключа и с неверным ключом.
Вне объёма (YAGNI)
- SQLite и запись на диск через native messaging (можно добавить отдельным этапом, если экспорта станет мало)
- Формула «просмотры ÷ медиана канала»
- Синхронизация между устройствами
- Публикация в Chrome Web Store