From 15ac3ba5706c97b982a2f992d0c03b1ab70792d4 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sun, 25 Oct 2026 10:57:49 +0300 Subject: [PATCH] feat: add content script that collects video ids from YouTube pages Co-Authored-By: Claude Sonnet 5.5 --- manifest.json | 15 +++++++++- src/content.js | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/content.js diff --git a/manifest.json b/manifest.json index d5e6467..c3416c6 100644 --- a/manifest.json +++ b/manifest.json @@ -7,5 +7,18 @@ "host_permissions": ["https://www.googleapis.com/youtube/v3/*"], "background": { "service_worker": "src/background.js", "type": "module" }, "action": { "default_title": "Outlier Saver", "default_popup": "src/popup/popup.html" }, - "options_ui": { "page": "src/options/options.html", "open_in_tab": true } + "options_ui": { "page": "src/options/options.html", "open_in_tab": true }, + "content_scripts": [ + { + "matches": ["https://www.youtube.com/*"], + "js": ["src/content.js"], + "run_at": "document_idle" + } + ], + "web_accessible_resources": [ + { + "resources": ["src/lib/video-id.js", "src/lib/vidiq.js"], + "matches": ["https://www.youtube.com/*"] + } + ] } diff --git a/src/content.js b/src/content.js new file mode 100644 index 0000000..f4a1ea6 --- /dev/null +++ b/src/content.js @@ -0,0 +1,76 @@ +(async () => { + const { parseVideoLink, pageFromPath } = await import(chrome.runtime.getURL('src/lib/video-id.js')); + const { readVidiqScore } = await import(chrome.runtime.getURL('src/lib/vidiq.js')); + + const LINK_SELECTOR = 'a[href*="/watch?v="], a[href*="/shorts/"]'; + // Only used to find the element that contains a VidIQ badge; ID collection does not depend on it. + 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(','); + const SCAN_INTERVAL_MS = 500; + + // videoId -> true if already sent together with a VidIQ score, false if sent without one. + let sent = new Map(); + let scanTimer = null; + + function collectItems(page) { + const items = new Map(); + const consider = (parsed, card) => { + const state = sent.get(parsed.videoId); + if (state === true) return; + const vidiqScore = readVidiqScore(card); + if (state === false && vidiqScore === null) return; + const item = items.get(parsed.videoId) ?? { + videoId: parsed.videoId, + vidiqScore: null, + viaShortsUrl: false, + page, + }; + item.vidiqScore ??= vidiqScore; + item.viaShortsUrl ||= parsed.isShort; + items.set(parsed.videoId, item); + }; + + for (const link of document.querySelectorAll(LINK_SELECTOR)) { + const parsed = parseVideoLink(link.getAttribute('href')); + if (parsed) consider(parsed, link.closest(CARD_SELECTOR) ?? link.parentElement); + } + if (page === 'watch') { + const current = parseVideoLink(location.href); + if (current) consider(current, null); + } + return [...items.values()]; + } + + function scan() { + scanTimer = null; + const page = pageFromPath(location.pathname); + if (!page) return; + const items = collectItems(page); + if (items.length === 0) return; + for (const item of items) sent.set(item.videoId, item.vidiqScore !== null); + try { + chrome.runtime.sendMessage({ type: 'videos-seen', items }).catch(() => {}); + } catch { + observer.disconnect(); // the extension was reloaded; this script is orphaned + } + } + + 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(); +})();