commit aab868c9377d62e7436f81f2bf2fa9a89ee91fcb Author: IgorVolochay Date: Sun Oct 25 10:37:46 2026 +0300 Add design spec for outlier saver extension Co-Authored-By: Claude Sonnet 5.5 diff --git a/docs/superpowers/specs/2026-10-25-outlier-saver-design.md b/docs/superpowers/specs/2026-10-25-outlier-saver-design.md new file mode 100644 index 0000000..33df964 --- /dev/null +++ b/docs/superpowers/specs/2026-10-25-outlier-saver-design.md @@ -0,0 +1,121 @@ +# 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, вызовы API, расчёт score, решение «сохранять или нет», запись в БД, бейдж на иконке | +| Формула и решение | `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 не мешает. + +## Поток данных + +1. Content script на `youtube.com` реагирует на событие `yt-navigate-finish` (YouTube — SPA, страницы не перезагружаются) и на `MutationObserver` по документу. Срабатывание дебаунсится на 500 мс. +2. Из ссылок `a[href*="/watch?v="]` и `a[href*="/shorts/"]` через `video-id.js` берутся ID. Для каждой карточки пробуется прочитать значок VidIQ. Уже отправленные с этой страницы ID запоминаются в `Set`, чтобы не спамить сообщениями. +3. Content script шлёт background сообщение `{videoId, vidiqScore | null, viaShortsUrl, page}`, где `page` — `home | watch | search | channel`. +4. Background копит ID и сбрасывает очередь раз в секунду либо при накоплении 50 штук. ID, проверенные менее 24 часов назад (`seen.checkedAt`), пропускаются. +5. `videos.list` (`part=snippet,statistics,contentDetails`) возвращает название, превью, просмотры, дату публикации, длительность и `channelId`. +6. Для каналов, которых нет в кэше `channels` или чья запись старше 24 часов, вызывается `channels.list` (`part=statistics,snippet`). +7. `score = views / max(subscribers, 1)`. Если подписчики скрыты, `score = null`. +8. Решение о сохранении (`shouldSave`): видео сохраняется, если `views ≥ minViews` и `score ≥ threshold`, **или** если `vidiqScore` не `null`. Условие по VidIQ действует независимо от своего порога и от `minViews`. +9. Результат пишется upsert-ом в `outliers`, ID с временем проверки — в `seen` (в том числе несохранённые, чтобы не запрашивать их повторно). + +## Модель данных + +### Хранилище `outliers` (ключ — `videoId`) + +| Поле | Описание | +|---|---| +| `videoId` | ID видео | +| `title` | Название | +| `url` | `https://www.youtube.com/watch?v=` для всех видео, включая 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 выгрузили | Очередь живёт в памяти и может потеряться. Content script пришлёт ID снова при следующем сканировании, так как `seen` пишется только после результата | +| Повторная встреча видео | 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