docs: redesign around VidIQ data and add the card fixture

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
IgorVolochay
2026-10-25 12:03:18 +03:00
co-authored by Claude Sonnet 5.5
parent 8346774bd5
commit 39af9a3111
4 changed files with 1669 additions and 80 deletions
@@ -1,3 +1,5 @@
> **Заменён:** этот план описывает версию 1 (YouTube Data API). Актуальный план — `2026-10-25-vidiq-rework.md`.
# Outlier Saver Implementation Plan # Outlier Saver Implementation Plan
> **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. > **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.
File diff suppressed because it is too large Load Diff
@@ -1,123 +1,113 @@
# Outlier Saver — дизайн расширения # Outlier Saver — дизайн расширения (версия 2: данные VidIQ)
Дата: 2026-10-25 Дата: 2026-10-25. Версия 1 (YouTube Data API и свой расчёт score) снята: API недоступен в регионе пользователя. Предыдущая версия лежит в истории git (коммит `05d8bff`).
## Цель ## Цель
Расширение для Chrome (Manifest V3), которое работает, пока пользователь просто смотрит YouTube: само находит видео с аномально высокими просмотрами относительно размера канала («outliers») и сохраняет их локально. Позже данные выгружаются в JSON или CSV. Расширение для Chrome (Manifest V3) для личного использования. Пока пользователь смотрит YouTube с установленным VidIQ, оно читает карточки видео, берёт у VidIQ outlier score и число подписчиков и сохраняет видео с высоким score локально. Позже данные выгружаются в JSON или CSV.
## Принятые решения ## Принятые решения
| Вопрос | Решение | | Вопрос | Решение |
|---|---| |---|---|
| Источник outlier score | Гибрид: свой расчёт + значок VidIQ, если он есть на карточке | | Источник данных | Только DOM страницы YouTube. Никаких запросов к сети, API-ключей и host-разрешений |
| Хранение | IndexedDB в браузере, экспорт в JSON и CSV из popup (SQLite не используем) | | Outlier score | Берётся у VidIQ, своя формула удалена |
| Формула | `score = просмотры ÷ подписчики` | | Подписчики | Берутся у VidIQ из карточки (округлены, как на экране) |
| Страницы | Главная (лента), страница просмотра, поиск, страница канала | | Просмотры | Берутся из разметки YouTube (округлены, как на экране) |
| Источник цифр | YouTube Data API v3 (нужен ключ пользователя) | | Порог | Сохраняем при `outlierScore ≥ threshold` и `views ≥ minViews` (по умолчанию 5 и 10 000, меняются в настройках) |
| Хранение | IndexedDB в браузере, экспорт в JSON и CSV из popup |
| Страницы | Главная, страница просмотра (боковые карточки), поиск, страница канала |
| Языки интерфейса | Русский и английский |
| Сборка | Нет. Чистый JS, ES-модули, загрузка как «распакованное расширение» | | Сборка | Нет. Чистый 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.js` | Следит за DOM на `youtube.com`, достаёт ID видео из ссылок, читает значок VidIQ, шлёт пачки в background | | Content script | `src/content.js` | Наблюдает за DOM на `youtube.com`, разбирает карточки, отправляет в background только карточки, где VidIQ уже вывел score, и только когда данные изменились |
| Service worker | `src/background.js` | Очередь ID, цикл сброса пачками, статус и бейдж на иконке, реакция на смену ключа | | Service worker | `src/background.js` | Принимает карточки, подтягивает настройки и ранее сохранённые записи, пишет результат в IndexedDB. Записи обрабатываются строго по очереди |
| Обработка пачки | `src/lib/process.js` | `processBatch`: проверка `seen`, запросы к API, расчёт score, решение «сохранять или нет», запись в БД. Зависимости (БД и API) передаются параметрами, поэтому логика покрыта тестами | | Разбор карточки | `src/lib/card.js` | `scrapeCard(card, link, context)`: достаёт поля из DOM-элемента карточки |
| Настройки и статус | `src/lib/storage.js` | Значения по умолчанию, `normalizeSettings`, чтение и запись настроек и статуса в `chrome.storage.local` | | Данные VidIQ | `src/lib/vidiq.js` | Селекторы VidIQ, `outlierScoreFromHref`, `parseVidiqScore`, `readVidiqData` |
| Формула и решение | `src/lib/score.js` | Чистые функции: `computeScore`, `shouldSave` | | Числа | `src/lib/numbers.js` | `parseCount`, `parseViews`: «59 млн», «571 тыс.», «1.2M», «12 345» → число |
| Разбор ссылок | `src/lib/video-id.js` | Извлечение ID из `/watch?v=`, `/shorts/`, отсев мусора, определение типа страницы по пути | | Ссылки | `src/lib/video-id.js` | `isVideoId`, `parseVideoLink`, `pageFromPath` |
| Значок VidIQ | `src/lib/vidiq.js` | Селектор значка (одна константа) и `parseVidiqScore(text)` | | Запись | `src/lib/record.js` | `shouldSave`, `sanitizeItem` (проверка входящего сообщения), `buildRecord` (создание и обновление записи) |
| API | `src/lib/api.js` | `videos.list`, `channels.list`, разбиение на пачки по 50, разбор ошибок | | Настройки | `src/lib/storage.js` | Значения по умолчанию, `normalizeSettings`, чтение и запись настроек |
| База | `src/lib/db.js` | Обёртка над IndexedDB | | База | `src/lib/db.js` | Обёртка над IndexedDB, одно хранилище `outliers` |
| Экспорт | `src/lib/export.js` | Сериализация в JSON и CSV | | Экспорт | `src/lib/export.js` | JSON и CSV |
| Popup | `src/popup/` | Счётчик, список последних находок, кнопки экспорта, статус сбора | | Popup | `src/popup/` | Счётчик, список последних находок, кнопки экспорта |
| Options | `src/options/` | API-ключ, порог score, минимум просмотров | | Options | `src/options/` | Порог score и минимум просмотров |
Ключ API хранится в `chrome.storage.local` и читается только в background и на странице настроек. Content script его не использует. Запросы к API идут из background, а не со страницы, поэтому CORS не мешает.
## Поток данных ## Поток данных
1. Content script на `youtube.com` реагирует на событие `yt-navigate-finish` (YouTube — SPA, страницы не перезагружаются) и на `MutationObserver` по документу. Сканирование запускается не чаще раза в 500 мс (троттлинг, а не дебаунс: пока играет видео, DOM меняется постоянно, и дебаунс не сработал бы). После навигации список уже отправленных ID сбрасывается. 1. Content script запускает сканирование не чаще раза в 500 мс: после изменения DOM (`MutationObserver`) и после события `yt-navigate-finish` (YouTube — SPA). Это троттлинг, а не дебаунс: пока играет видео, DOM меняется постоянно. После навигации список уже отправленного сбрасывается.
2. Из ссылок `a[href*="/watch?v="]` и `a[href*="/shorts/"]` через `video-id.js` берутся ID. Для каждой карточки пробуется прочитать значок VidIQ. Уже отправленные с этой страницы ID запоминаются в `Set`, чтобы не спамить сообщениями. 2. Для каждой ссылки `/watch?v=` и `/shorts/` ищется ближайший элемент карточки, и `scrapeCard` читает поля. Карточка пропускается, если нет `outlierScore` (VidIQ ещё не отрисовал блок: она подхватится при следующем сканировании) или нет `views`.
3. Content script шлёт background сообщение `{videoId, vidiqScore | null, viaShortsUrl, page}`, где `page` — `home | watch | search | channel`. 3. Для каждой карточки считается подпись `outlierScore|views|subscribers`. Карточка отправляется, только если подпись изменилась с прошлой отправки: VidIQ может дорисовать или обновить числа позже.
4. Background копит ID и сбрасывает очередь раз в секунду либо при накоплении 50 штук. ID, проверенные менее 24 часов назад (`seen.checkedAt`), пропускаются, кроме ID со значком VidIQ: значок может отрисоваться позже первого сканирования, поэтому content script отправляет ID повторно, когда у него появился score, а проверка `seen` для таких ID не применяется. 4. Background проверяет сообщение (`sanitizeItem`), читает настройки и существующие записи, для каждой карточки вызывает `buildRecord`, и записывает результат в IndexedDB.
5. `videos.list` (`part=snippet,statistics,contentDetails`) возвращает название, превью, просмотры, дату публикации, длительность и `channelId`. 5. `buildRecord`: новое видео сохраняется, если выполнено условие порога. Уже сохранённое видео обновляется всегда, даже если его score упал ниже порога.
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`. Уже сохранённое видео обновляется при каждой проверке, даже если его score опустился ниже порога.
9. Результат пишется upsert-ом в `outliers`, ID с временем проверки — в `seen` (в том числе несохранённые, чтобы не запрашивать их повторно).
## Модель данных ## Модель данных
### Хранилище `outliers` (ключ — `videoId`) Хранилище `outliers` (ключ — `videoId`, индекс по `updatedAt`):
| Поле | Описание | | Поле | Описание |
|---|---| |---|---|
| `videoId` | ID видео | | `videoId` | ID видео |
| `title` | Название | | `title` | Название (не длиннее 300 символов) |
| `url` | `https://www.youtube.com/watch?v=<videoId>` для всех видео, включая Shorts | | `url` | `https://www.youtube.com/watch?v=<videoId>` для всех видео, включая Shorts |
| `thumbnailUrl` | Лучшее доступное превью в порядке `maxres → standard → high → medium → default` | | `thumbnailUrl` | Ссылка на превью |
| `channelId`, `channelTitle`, `channelUrl` | Канал | | `channelTitle`, `channelUrl` | Канал (`null`, если не удалось определить) |
| `subscribers` | Число подписчиков или `null`, если скрыто. API округляет до трёх значащих цифр | | `subscribers` | Число подписчиков от VidIQ или `null` |
| `views` | Просмотры на момент последней проверки | | `views` | Просмотры на момент последней проверки |
| `publishedAt` | ISO-дата публикации | | `outlierScore` | Outlier score от VidIQ, до двух знаков |
| `durationSec` | Длительность | | `isShort` | `true`, если видео хотя бы раз встретилось по ссылке `/shorts/` |
| `isShort` | `true`, если хотя бы раз видео встретилось по ссылке `/shorts/` | | `foundOn` | Страница первой находки: `home`, `watch`, `search` или `channel` |
| `score` | Свой score, округлённый до двух знаков, или `null` | | `firstSeenAt`, `updatedAt` | Временные метки (ISO). `firstSeenAt` не меняется при обновлении |
| `vidiqScore` | Число со значка VidIQ или `null` |
| `foundOn` | Страница первой находки |
| `firstSeenAt`, `updatedAt` | Временные метки (ISO). `firstSeenAt` не меняется при upsert |
### Хранилище `channels` (ключ — `channelId`) CSV содержит эти же поля в том же порядке.
`channelId`, `title`, `subscribers` (число или `null`), `fetchedAt`. ## Настройки (`chrome.storage.local`, ключ `settings`)
### Хранилище `seen` (ключ — `videoId`) | Поле | По умолчанию |
`videoId`, `checkedAt`, `status` (`ok` или `unavailable`). После 24 часов запись считается устаревшей и видео запрашивается заново. Это нужно, чтобы видео, которое стало outlier позже, не потерялось, а просмотры у сохранённых обновлялись.
## Настройки (`chrome.storage.local`)
| Ключ | По умолчанию |
|---|---| |---|---|
| `apiKey` | пусто |
| `threshold` | 5 | | `threshold` | 5 |
| `minViews` | 10 000 | | `minViews` | 10 000 |
## Ошибки и граничные случаи ## Граничные случаи
| Ситуация | Поведение | | Ситуация | Поведение |
|---|---| |---|---|
| Ключ API не задан | Сбор приостановлен, на иконке бейдж «!», popup ведёт в настройки. В `seen` ничего не пишется, после ввода ключа видео подхватятся | | VidIQ не установлен или ещё не отрисовал блок | Карточка пропускается и проверяется при следующем сканировании. Если VidIQ нет совсем, ничего не сохраняется, popup подсказывает про VidIQ |
| Квота исчерпана (403 `quotaExceeded`) | Пауза до сброса квоты (полночь по Тихоокеанскому времени), popup показывает причину. Необработанные ID в `seen` не попадают | | Не удалось разобрать просмотры (другой язык, прямой эфир) | Карточка пропускается |
| Невалидный ключ (400 или 403 `keyInvalid`) | Как при отсутствии ключа, с сообщением об ошибке ключа | | VidIQ скрыл подписчиков | `subscribers: null`, видео сохраняется по score |
| Сеть или 5xx | Три повтора с нарастающей задержкой, затем ID пропускается без записи в `seen` | | Повторная встреча видео | Обновляются `views`, `outlierScore`, `subscribers`, `updatedAt`. `firstSeenAt` и `foundOn` не меняются, `isShort` может только стать `true` |
| Скрытые подписчики | `score = null`, видео сохраняется только при наличии `vidiqScore` | | Расширение перезагружено | Старый content script отключается сам |
| Очень мало подписчиков | Делитель `max(subs, 1)`, мусор отсекается через `minViews` | | Сообщение с некорректными данными | Отбрасывается в `sanitizeItem`: проверяются ID, числа, ссылки (`youtube.com`, `i.ytimg.com`) |
| Приватное или удалённое видео | API его не возвращает; в `seen` пишется `status: unavailable`, повторная проверка после TTL | | YouTube или VidIQ поменяли разметку | Тест на фикстурах в реальном Chrome покажет, что именно перестало разбираться |
| 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` (экранирование запятых, кавычек и переносов строк в названиях). - **Автотесты** на встроенном `node:test`, без зависимостей: `numbers.js`, `vidiq.js` (на подставных объектах), `record.js`, `video-id.js`, `export.js`, `storage.js`.
- **Ручной чек-лист** на живом YouTube: подгрузка карточек при скролле на главной; переход между страницами без перезагрузки; работа на `/results` и `/@channel/videos`; запись появляется в popup; скачивание JSON и CSV; поведение без ключа и с неверным ключом. - **Тест разбора карточки в Chrome** (`test/card.dom.test.js`): запускает headless Chrome, загружает фикстуры и проверяет `scrapeCard`. Фикстуры: настоящая карточка с VidIQ с главной, плюс упрощённые карточки поиска и боковой панели. Если Chrome не найден, тест пропускается.
- **Сквозная проверка в Chrome** (не входит в репозиторий): живой YouTube с подмешанным в карточки блоком VidIQ, проход через content script, background, IndexedDB и popup.
## Вне объёма (YAGNI) ## Вне объёма (YAGNI)
- SQLite и запись на диск через native messaging (можно добавить отдельным этапом, если экспорта станет мало) - Свой расчёт score, обращения к YouTube Data API и любым другим сетевым источникам
- Формула «просмотры ÷ медиана канала» - Открытое видео на странице просмотра (собираются только карточки)
- Синхронизация между устройствами - Языки интерфейса кроме русского и английского
- Публикация в Chrome Web Store - Точные просмотры и подписчики (берутся значения с экрана)
- SQLite, синхронизация между устройствами, публикация в Chrome Web Store
+1
View File
File diff suppressed because one or more lines are too long