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>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
38dabd9fa2
commit
b137776381
@@ -24,10 +24,18 @@
|
|||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
Сборки нет. Тесты (Node 22, без зависимостей; тест разбора карточки запускает headless Chrome и пропускается, если Chrome не найден):
|
Зависимостей нет, сборка только одна (content script, см. ниже). Тесты (Node 22; тест разбора карточки запускает headless Chrome и пропускается, если Chrome не найден):
|
||||||
|
|
||||||
```
|
```
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Content script собирается в один файл: после правки `src/content/main.js` или модулей из `src/lib/`, которые он использует (`card`, `numbers`, `vidiq`, `video-id`), выполните
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Собранный `src/content.js` лежит в репозитории, тест `bundle` падает, если он отстал от исходников. Динамический `import()` в content script не используется: Chrome оставляет из-за него ошибки в `chrome://extensions`.
|
||||||
|
|
||||||
После изменения кода нажмите «Обновить» у расширения на `chrome://extensions`.
|
После изменения кода нажмите «Обновить» у расширения на `chrome://extensions`.
|
||||||
|
|||||||
@@ -1594,3 +1594,21 @@ Expected: все проверки проходят.
|
|||||||
git add README.md
|
git add README.md
|
||||||
git commit -m "docs: describe the VidIQ-based workflow" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
git commit -m "docs: describe the VidIQ-based workflow" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10 (добавлена после проверки в браузере): один обычный файл для content script
|
||||||
|
|
||||||
|
**Почему:** в `chrome://extensions` у расширения копились ошибки «Unsafe attempt to load URL chrome-extension://…/src/lib/video-id.js from frame with URL https://www.youtube.com/…». Разбор показал: Chrome пишет это сообщение при динамическом `import()` модулей из content script (недетерминированно, воспроизводится даже на минимальном примере из двух файлов), хотя запросы завершаются статусом 200 и расширение работает. Чтобы не копить ошибки, content script собирается в один обычный файл без `import()`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/build-content.js` (функция `bundle(entry)` и запуск `npm run build`), `src/content/main.js` (исходник content script на статических `import`), `test/bundle.test.js`
|
||||||
|
- Generate: `src/content.js` (`npm run build`, коммитится)
|
||||||
|
- Modify: `manifest.json` (убрать `web_accessible_resources`), `package.json` (скрипт `build`), `README.md`, спецификация
|
||||||
|
|
||||||
|
**Что проверено:**
|
||||||
|
- [x] `test/bundle.test.js` (6 тестов): порядок модулей, общий модуль включён один раз, отказ при совпадающих именах и неподдерживаемом `export`, закоммиченный `src/content.js` совпадает со свежей сборкой, в нём нет `import()`, в манифесте нет `web_accessible_resources`.
|
||||||
|
- [x] Три прогона воспроизведения на главной, поиске, просмотре и канале: сообщений «Unsafe attempt» нет (раньше не меньше четырёх на страницу).
|
||||||
|
- [x] Сквозная проверка в Chrome (26 проверок) проходит на собранном файле.
|
||||||
|
|
||||||
|
**Правило разработки:** после правки `src/content/main.js` или модулей из `src/lib/`, которые он использует, выполните `npm run build`; без этого тест `bundle.test.js` падает.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
| Хранение | IndexedDB в браузере, экспорт в JSON и CSV из popup |
|
| Хранение | IndexedDB в браузере, экспорт в JSON и CSV из popup |
|
||||||
| Страницы | Главная, страница просмотра (боковые карточки), поиск, страница канала |
|
| Страницы | Главная, страница просмотра (боковые карточки), поиск, страница канала |
|
||||||
| Языки интерфейса | Русский и английский |
|
| Языки интерфейса | Русский и английский |
|
||||||
| Сборка | Нет. Чистый JS, ES-модули, загрузка как «распакованное расширение» |
|
| Сборка | Только для 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-модули, загрузка как «распакованное расширение» |
|
||||||
|
|
||||||
## Откуда берётся каждое поле
|
## Откуда берётся каждое поле
|
||||||
|
|
||||||
@@ -38,7 +38,8 @@
|
|||||||
|
|
||||||
| Часть | Файл | Ответственность |
|
| Часть | Файл | Ответственность |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Content script | `src/content.js` | Наблюдает за DOM на `youtube.com`, разбирает карточки, отправляет в background только карточки, где VidIQ уже вывел score, и только когда данные изменились |
|
| 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. Записи обрабатываются строго по очереди |
|
| Service worker | `src/background.js` | Принимает карточки, подтягивает настройки и ранее сохранённые записи, пишет результат в IndexedDB. Записи обрабатываются строго по очереди |
|
||||||
| Разбор карточки | `src/lib/card.js` | `scrapeCard(card, link, context)`: достаёт поля из DOM-элемента карточки |
|
| Разбор карточки | `src/lib/card.js` | `scrapeCard(card, link, context)`: достаёт поля из DOM-элемента карточки |
|
||||||
| Данные VidIQ | `src/lib/vidiq.js` | Селекторы VidIQ, `outlierScoreFromHref`, `parseVidiqScore`, `readVidiqData` |
|
| Данные VidIQ | `src/lib/vidiq.js` | Селекторы VidIQ, `outlierScoreFromHref`, `parseVidiqScore`, `readVidiqData` |
|
||||||
@@ -101,6 +102,7 @@ CSV содержит эти же поля в том же порядке.
|
|||||||
## Тестирование
|
## Тестирование
|
||||||
|
|
||||||
- **Автотесты** на встроенном `node:test`, без зависимостей: `numbers.js`, `vidiq.js` (на подставных объектах), `record.js`, `video-id.js`, `export.js`, `storage.js`.
|
- **Автотесты** на встроенном `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** (`test/card.dom.test.js`): запускает headless Chrome, загружает фикстуры и проверяет `scrapeCard`. Фикстуры: настоящая карточка с VidIQ с главной, плюс упрощённые карточки поиска и боковой панели. Если Chrome не найден, тест пропускается.
|
||||||
- **Сквозная проверка в Chrome** (не входит в репозиторий): живой YouTube с подмешанным в карточки блоком VidIQ, проход через content script, background, IndexedDB и popup.
|
- **Сквозная проверка в Chrome** (не входит в репозиторий): живой YouTube с подмешанным в карточки блоком VidIQ, проход через content script, background, IndexedDB и popup.
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,5 @@
|
|||||||
"js": ["src/content.js"],
|
"js": ["src/content.js"],
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
}
|
}
|
||||||
],
|
|
||||||
"web_accessible_resources": [
|
|
||||||
{
|
|
||||||
"resources": ["src/lib/*.js"],
|
|
||||||
"matches": ["https://www.youtube.com/*"]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "node tools/build-content.js",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+194
-3
@@ -1,7 +1,198 @@
|
|||||||
(async () => {
|
// GENERATED by tools/build-content.js from src/content/main.js and the modules it imports.
|
||||||
const { pageFromPath } = await import(chrome.runtime.getURL('src/lib/video-id.js'));
|
// Edit those files and run `npm run build`; do not edit this file by hand.
|
||||||
const { CARD_SELECTOR, LINK_SELECTOR, scrapeCard } = await import(chrome.runtime.getURL('src/lib/card.js'));
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---- numbers.js ----
|
||||||
|
const UNITS = new Map([
|
||||||
|
['k', 1e3], ['тыс', 1e3], ['тысяча', 1e3], ['тысячи', 1e3], ['тысяч', 1e3], ['thousand', 1e3],
|
||||||
|
['m', 1e6], ['млн', 1e6], ['миллион', 1e6], ['миллиона', 1e6], ['миллионов', 1e6], ['million', 1e6],
|
||||||
|
['b', 1e9], ['млрд', 1e9], ['миллиард', 1e9], ['миллиарда', 1e9], ['миллиардов', 1e9], ['billion', 1e9],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// "просмотров", "подписчиков", "views", "subscribers" and their other forms at the end of the text.
|
||||||
|
const COUNT_WORDS = /\s*(просмотров|просмотра|просмотр|подписчиков|подписчика|подписчики|подписчик|views?|subscribers?)$/;
|
||||||
|
const GROUPED_INTEGER = /^\d{1,3}(?:[ ,.]\d{3})+$/;
|
||||||
|
const DECIMAL = /^\d+(?:[.,]\d+)?$/;
|
||||||
|
|
||||||
|
// Reads "59 млн", "571 тыс.", "1.2M", "12 345" and the like; the whole text must be a count.
|
||||||
|
function parseCount(text) {
|
||||||
|
if (typeof text !== 'string') return null;
|
||||||
|
const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase().replace(COUNT_WORDS, '').trim();
|
||||||
|
const match = /^(\d[\d ,.]*?)(?: ?([a-zа-яё]+)\.?)?$/.exec(normalized);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, number, unit] = match;
|
||||||
|
|
||||||
|
if (unit === undefined) {
|
||||||
|
if (/^\d+$/.test(number)) return Number(number);
|
||||||
|
return GROUPED_INTEGER.test(number) ? Number(number.replace(/\D/g, '')) : null;
|
||||||
|
}
|
||||||
|
const multiplier = UNITS.get(unit);
|
||||||
|
if (multiplier === undefined || !DECIMAL.test(number)) return null;
|
||||||
|
return Math.round(Number(number.replace(',', '.')) * multiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseViews(text) {
|
||||||
|
if (typeof text !== 'string') return null;
|
||||||
|
if (/^\s*(no|нет) (views|просмотров)\s*$/i.test(text)) return 0;
|
||||||
|
return parseCount(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- video-id.js ----
|
||||||
|
const ID_RE = /^[A-Za-z0-9_-]{11}$/;
|
||||||
|
const YOUTUBE_HOSTS = new Set(['www.youtube.com', 'youtube.com', 'm.youtube.com']);
|
||||||
|
|
||||||
|
function isVideoId(value) {
|
||||||
|
return typeof value === 'string' && ID_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { videoId, isShort } for a link to a single video, or null for anything else.
|
||||||
|
function parseVideoLink(href, base = 'https://www.youtube.com') {
|
||||||
|
if (!href) return null;
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(href, base);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!YOUTUBE_HOSTS.has(url.hostname)) return null;
|
||||||
|
|
||||||
|
if (url.pathname === '/watch') {
|
||||||
|
const id = url.searchParams.get('v');
|
||||||
|
return isVideoId(id) ? { videoId: id, isShort: false } : null;
|
||||||
|
}
|
||||||
|
const shorts = url.pathname.match(/^\/shorts\/([^/]+)/);
|
||||||
|
if (shorts && isVideoId(shorts[1])) return { videoId: shorts[1], isShort: true };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageFromPath(pathname) {
|
||||||
|
if (pathname === '/') return 'home';
|
||||||
|
if (pathname === '/watch') return 'watch';
|
||||||
|
if (pathname === '/results') return 'search';
|
||||||
|
if (/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)/.test(pathname)) return 'channel';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- vidiq.js ----
|
||||||
|
// VidIQ's own class names are generated (vidiq-c-xxxx) and change between builds; these hooks are
|
||||||
|
// data attributes it sets deliberately.
|
||||||
|
const OUTLIER_SELECTOR = '[data-hover-popover="OutlierPopover"]';
|
||||||
|
const SUBSCRIBERS_SELECTOR = '[data-vidiq-subscriber-count]';
|
||||||
|
|
||||||
|
function parseVidiqScore(text) {
|
||||||
|
const match = /(\d+(?:[.,]\d+)?)/.exec(text ?? '');
|
||||||
|
return match ? Number(match[1].replace(',', '.')) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The badge link carries the exact value (e.g. 146.19) while the badge text is capped (">100x").
|
||||||
|
function outlierScoreFromHref(href) {
|
||||||
|
if (!href) return null;
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = new URL(href, 'https://app.vidiq.com').searchParams.get('outlier_score');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (raw === null || raw === '') return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) ? Math.round(value * 100) / 100 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVidiqData(card) {
|
||||||
|
const outlier = card.querySelector(OUTLIER_SELECTOR);
|
||||||
|
const href = outlier?.querySelector('a')?.getAttribute('href');
|
||||||
|
const outlierScore = outlier ? (outlierScoreFromHref(href) ?? parseVidiqScore(outlier.textContent)) : null;
|
||||||
|
const subscribersNode = card.querySelector(SUBSCRIBERS_SELECTOR);
|
||||||
|
return { outlierScore, subscribers: subscribersNode ? parseCount(subscribersNode.textContent) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- card.js ----
|
||||||
|
const LINK_SELECTOR = 'a[href*="/watch?v="], a[href*="/shorts/"]';
|
||||||
|
const CARD_SELECTOR = [
|
||||||
|
'ytd-rich-item-renderer',
|
||||||
|
'ytd-video-renderer',
|
||||||
|
'ytd-compact-video-renderer',
|
||||||
|
'ytd-grid-video-renderer',
|
||||||
|
'yt-lockup-view-model',
|
||||||
|
'ytm-shorts-lockup-view-model',
|
||||||
|
'ytd-reel-item-renderer',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
// Spans that hold the channel name, the view count and the age in the new and old card layouts.
|
||||||
|
const METADATA_TEXT = '.ytContentMetadataViewModelMetadataText, .inline-metadata-item, #metadata-line span';
|
||||||
|
const CHANNEL_LINK = 'a[href^="/@"], a[href^="/channel/"], a[href^="/c/"], a[href^="/user/"]';
|
||||||
|
const RELATIVE_TIME = /назад|(^|\s)ago(\s|$)/i;
|
||||||
|
|
||||||
|
// VidIQ puts its own text (subscribers, VPH, score) inside `.vidiq-scope`; it must not be read as YouTube's.
|
||||||
|
const outsideVidiq = (element) => !element.closest('.vidiq-scope');
|
||||||
|
|
||||||
|
function readTitle(card) {
|
||||||
|
const heading = card.querySelector('h3[title]')?.getAttribute('title')?.trim();
|
||||||
|
if (heading) return heading;
|
||||||
|
const oldTitle = card.querySelector('#video-title');
|
||||||
|
const oldText = (oldTitle?.getAttribute('title') || oldTitle?.textContent || '').trim();
|
||||||
|
if (oldText) return oldText;
|
||||||
|
return card.querySelector('.ytLockupMetadataViewModelTitle')?.textContent.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readThumbnail(card, videoId) {
|
||||||
|
const image = card.querySelector('img[src^="https://i.ytimg.com/vi/"], img[src^="https://i.ytimg.com/vi_webp/"]');
|
||||||
|
return image ? image.src : `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readViews(card) {
|
||||||
|
for (const element of card.querySelectorAll(METADATA_TEXT)) {
|
||||||
|
if (!outsideVidiq(element)) continue;
|
||||||
|
const views = parseViews(element.textContent) ?? parseViews(element.getAttribute('aria-label'));
|
||||||
|
if (views !== null) return views;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteChannelUrl(href) {
|
||||||
|
try {
|
||||||
|
const url = new URL(href, 'https://www.youtube.com');
|
||||||
|
return url.origin + url.pathname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChannel(card, context) {
|
||||||
|
const link = [...card.querySelectorAll(CHANNEL_LINK)].find((a) => outsideVidiq(a) && a.textContent.trim());
|
||||||
|
if (link) return { channelTitle: link.textContent.trim(), channelUrl: absoluteChannelUrl(link.getAttribute('href')) };
|
||||||
|
|
||||||
|
// New layouts show the channel name as plain text: the first metadata text that is neither a count nor an age.
|
||||||
|
for (const element of card.querySelectorAll(METADATA_TEXT)) {
|
||||||
|
const text = element.textContent.trim();
|
||||||
|
if (outsideVidiq(element) && text && parseViews(text) === null && !RELATIVE_TIME.test(text)) {
|
||||||
|
return { channelTitle: text, channelUrl: context?.channelUrl ?? null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { channelTitle: context?.channelTitle ?? null, channelUrl: context?.channelUrl ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads one video card. `context` supplies the channel on channel pages, where cards do not name it.
|
||||||
|
function scrapeCard(card, link, context = null) {
|
||||||
|
const parsed = parseVideoLink(link.getAttribute('href'));
|
||||||
|
if (!parsed) return null;
|
||||||
|
const { outlierScore, subscribers } = readVidiqData(card);
|
||||||
|
const { channelTitle, channelUrl } = readChannel(card, context);
|
||||||
|
return {
|
||||||
|
videoId: parsed.videoId,
|
||||||
|
isShort: parsed.isShort,
|
||||||
|
title: readTitle(card),
|
||||||
|
thumbnailUrl: readThumbnail(card, parsed.videoId),
|
||||||
|
channelTitle,
|
||||||
|
channelUrl,
|
||||||
|
subscribers,
|
||||||
|
views: readViews(card),
|
||||||
|
outlierScore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- main.js ----
|
||||||
const SCAN_INTERVAL_MS = 500;
|
const SCAN_INTERVAL_MS = 500;
|
||||||
const MAX_ITEMS_PER_MESSAGE = 200;
|
const MAX_ITEMS_PER_MESSAGE = 200;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { CARD_SELECTOR, LINK_SELECTOR, scrapeCard } from '../lib/card.js';
|
||||||
|
import { pageFromPath } from '../lib/video-id.js';
|
||||||
|
|
||||||
|
const SCAN_INTERVAL_MS = 500;
|
||||||
|
const MAX_ITEMS_PER_MESSAGE = 200;
|
||||||
|
|
||||||
|
// videoId -> "outlierScore|views|subscribers" of the last message that carried the video.
|
||||||
|
// VidIQ may draw or update its numbers after the card appears, so a changed signature is sent again.
|
||||||
|
let sent = new Map();
|
||||||
|
let scanTimer = null;
|
||||||
|
|
||||||
|
// Cards on a channel page do not name the channel, so take it from the page itself.
|
||||||
|
function channelContext() {
|
||||||
|
const [kind, name] = location.pathname.split('/').filter(Boolean);
|
||||||
|
const path = kind?.startsWith('@') ? kind : ['channel', 'c', 'user'].includes(kind) && name ? `${kind}/${name}` : null;
|
||||||
|
return {
|
||||||
|
channelUrl: path ? `https://www.youtube.com/${path}` : null,
|
||||||
|
channelTitle: document.title.replace(/^\(\d+\)\s*/, '').replace(/\s*-\s*YouTube$/, '') || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan() {
|
||||||
|
scanTimer = null;
|
||||||
|
const page = pageFromPath(location.pathname);
|
||||||
|
if (!page) return;
|
||||||
|
const context = page === 'channel' ? channelContext() : null;
|
||||||
|
|
||||||
|
const found = new Map();
|
||||||
|
const visited = new Set();
|
||||||
|
for (const link of document.querySelectorAll(LINK_SELECTOR)) {
|
||||||
|
const card = link.closest(CARD_SELECTOR);
|
||||||
|
if (!card || visited.has(card)) continue;
|
||||||
|
visited.add(card);
|
||||||
|
const item = scrapeCard(card, link, context);
|
||||||
|
if (!item || item.outlierScore === null || item.views === null) continue;
|
||||||
|
const signature = `${item.outlierScore}|${item.views}|${item.subscribers}`;
|
||||||
|
if (sent.get(item.videoId) === signature) continue;
|
||||||
|
found.set(item.videoId, { item: { ...item, page }, signature });
|
||||||
|
}
|
||||||
|
if (found.size === 0) return;
|
||||||
|
|
||||||
|
const batch = [...found.values()].slice(0, MAX_ITEMS_PER_MESSAGE);
|
||||||
|
for (const { item, signature } of batch) sent.set(item.videoId, signature);
|
||||||
|
try {
|
||||||
|
chrome.runtime.sendMessage({ type: 'cards', items: batch.map(({ item }) => item) }).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
observer.disconnect(); // the extension was reloaded; this script is orphaned
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (found.size > batch.length) scheduleScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleScan() {
|
||||||
|
if (scanTimer === null) scanTimer = setTimeout(scan, SCAN_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver(scheduleScan);
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
document.addEventListener('yt-navigate-finish', () => {
|
||||||
|
sent = new Map();
|
||||||
|
scheduleScan();
|
||||||
|
});
|
||||||
|
scheduleScan();
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { bundle } from '../tools/build-content.js';
|
||||||
|
|
||||||
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
|
||||||
|
function project(files) {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'outlier-saver-bundle-'));
|
||||||
|
for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('bundle puts dependencies before their users and leaves no import/export syntax', () => {
|
||||||
|
const dir = project({
|
||||||
|
'a.js': 'export const A = 1;\n',
|
||||||
|
'b.js': "import { A } from './a.js';\nexport function b() { return A + 1; }\n",
|
||||||
|
'main.js': "import { b } from './b.js';\nglobalThis.result = b();\n",
|
||||||
|
});
|
||||||
|
const output = bundle(join(dir, 'main.js'));
|
||||||
|
assert.ok(!/^\s*(import|export)\b/m.test(output), output);
|
||||||
|
const context = {};
|
||||||
|
new Function('globalThis', output)(context);
|
||||||
|
assert.equal(context.result, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundle includes a shared dependency only once', () => {
|
||||||
|
const dir = project({
|
||||||
|
'shared.js': 'export const SHARED = 7;\n',
|
||||||
|
'left.js': "import { SHARED } from './shared.js';\nexport const left = () => SHARED;\n",
|
||||||
|
'right.js': "import { SHARED } from './shared.js';\nexport const right = () => SHARED * 2;\n",
|
||||||
|
'main.js': "import { left } from './left.js';\nimport { right } from './right.js';\nglobalThis.sum = left() + right();\n",
|
||||||
|
});
|
||||||
|
const output = bundle(join(dir, 'main.js'));
|
||||||
|
assert.equal(output.match(/const SHARED/g).length, 1);
|
||||||
|
const context = {};
|
||||||
|
new Function('globalThis', output)(context);
|
||||||
|
assert.equal(context.sum, 21);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundle refuses two modules that declare the same top-level name', () => {
|
||||||
|
const dir = project({
|
||||||
|
'a.js': 'export const X = 1;\n',
|
||||||
|
'b.js': 'export const X = 2;\n',
|
||||||
|
'main.js': "import { X } from './a.js';\nimport './b.js';\n",
|
||||||
|
});
|
||||||
|
assert.throws(() => bundle(join(dir, 'main.js')), /Duplicate top-level name "X"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundle refuses export forms it cannot rewrite', () => {
|
||||||
|
for (const line of ['export default 1;', 'const a = 1;\nexport { a };', "export * from './x.js';"]) {
|
||||||
|
const dir = project({ 'main.js': `${line}\n` });
|
||||||
|
assert.throws(() => bundle(join(dir, 'main.js')), /only "export const/, line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the committed src/content.js is up to date (run `npm run build` if this fails)', () => {
|
||||||
|
const built = bundle(join(root, 'src/content/main.js'));
|
||||||
|
assert.equal(readFileSync(join(root, 'src/content.js'), 'utf8'), built);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the content script has no dynamic import and the manifest has no web_accessible_resources', () => {
|
||||||
|
assert.ok(!/\bimport\s*\(/.test(readFileSync(join(root, 'src/content.js'), 'utf8')));
|
||||||
|
assert.equal('web_accessible_resources' in JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Bundles the content script and the modules it imports into one classic script.
|
||||||
|
//
|
||||||
|
// Content scripts cannot be ES modules, and loading modules with a dynamic import() makes Chrome log
|
||||||
|
// "Unsafe attempt to load URL chrome-extension://..." on every YouTube page, which piles up in the
|
||||||
|
// extension's error list. A single classic file avoids both.
|
||||||
|
//
|
||||||
|
// Supported source style: one-line `import ... from './x.js';`, and `export` only before
|
||||||
|
// const / let / function / class. Top-level names must be unique across the bundled modules.
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { basename, dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const IMPORT = /^import\s+(?:[^'"]*?\s+from\s+)?'([^']+)';?[ \t]*\r?\n/gm;
|
||||||
|
const EXPORT_DECLARATION = /^export\s+(?=(?:async\s+function|function|const|let|class)\b)/gm;
|
||||||
|
const UNSUPPORTED_EXPORT = /^export\s+(?!(?:async\s+function|function|const|let|class)\b)/m;
|
||||||
|
const TOP_LEVEL_NAME = /^(?:async\s+)?(?:function\*?|const|let|class)\s+([A-Za-z_$][\w$]*)/gm;
|
||||||
|
|
||||||
|
const HEADER =
|
||||||
|
'// GENERATED by tools/build-content.js from src/content/main.js and the modules it imports.\n' +
|
||||||
|
'// Edit those files and run `npm run build`; do not edit this file by hand.\n';
|
||||||
|
|
||||||
|
export function bundle(entry) {
|
||||||
|
const order = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const visit = (file) => {
|
||||||
|
if (seen.has(file)) return;
|
||||||
|
seen.add(file);
|
||||||
|
for (const [, specifier] of readFileSync(file, 'utf8').matchAll(IMPORT)) visit(resolve(dirname(file), specifier));
|
||||||
|
order.push(file);
|
||||||
|
};
|
||||||
|
visit(resolve(entry));
|
||||||
|
|
||||||
|
const owner = new Map();
|
||||||
|
const parts = order.map((file) => {
|
||||||
|
const source = readFileSync(file, 'utf8');
|
||||||
|
if (UNSUPPORTED_EXPORT.test(source)) {
|
||||||
|
throw new Error(`${file}: only "export const/let/function/class" declarations are supported`);
|
||||||
|
}
|
||||||
|
const code = source.replace(IMPORT, '').replace(EXPORT_DECLARATION, '').trim();
|
||||||
|
for (const [, name] of code.matchAll(TOP_LEVEL_NAME)) {
|
||||||
|
if (owner.has(name)) throw new Error(`Duplicate top-level name "${name}" in ${file} and ${owner.get(name)}`);
|
||||||
|
owner.set(name, file);
|
||||||
|
}
|
||||||
|
return `// ---- ${basename(file)} ----\n${code}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${HEADER}(() => {\n'use strict';\n\n${parts.join('\n')}})();\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||||
|
if (isMain) {
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
writeFileSync(resolve(root, 'src/content.js'), bundle(resolve(root, 'src/content/main.js')));
|
||||||
|
console.log('Wrote src/content.js');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user