feat: scan video cards and send VidIQ-scored ones to the service worker
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
cdf8c08c7f
commit
fe65aee032
+33
-44
@@ -1,65 +1,54 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
const { parseVideoLink, pageFromPath } = await import(chrome.runtime.getURL('src/lib/video-id.js'));
|
const { pageFromPath } = await import(chrome.runtime.getURL('src/lib/video-id.js'));
|
||||||
const { readVidiqScore } = await import(chrome.runtime.getURL('src/lib/vidiq.js'));
|
const { CARD_SELECTOR, LINK_SELECTOR, scrapeCard } = await import(chrome.runtime.getURL('src/lib/card.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;
|
const SCAN_INTERVAL_MS = 500;
|
||||||
|
const MAX_ITEMS_PER_MESSAGE = 200;
|
||||||
|
|
||||||
// videoId -> true if already sent together with a VidIQ score, false if sent without one.
|
// 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 sent = new Map();
|
||||||
let scanTimer = null;
|
let scanTimer = null;
|
||||||
|
|
||||||
function collectItems(page) {
|
// Cards on a channel page do not name the channel, so take it from the page itself.
|
||||||
const items = new Map();
|
function channelContext() {
|
||||||
const consider = (parsed, card) => {
|
const [kind, name] = location.pathname.split('/').filter(Boolean);
|
||||||
const state = sent.get(parsed.videoId);
|
const path = kind?.startsWith('@') ? kind : ['channel', 'c', 'user'].includes(kind) && name ? `${kind}/${name}` : null;
|
||||||
if (state === true) return;
|
return {
|
||||||
const vidiqScore = readVidiqScore(card);
|
channelUrl: path ? `https://www.youtube.com/${path}` : null,
|
||||||
if (state === false && vidiqScore === null) return;
|
channelTitle: document.title.replace(/^\(\d+\)\s*/, '').replace(/\s*-\s*YouTube$/, '') || null,
|
||||||
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() {
|
function scan() {
|
||||||
scanTimer = null;
|
scanTimer = null;
|
||||||
const page = pageFromPath(location.pathname);
|
const page = pageFromPath(location.pathname);
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
const items = collectItems(page);
|
const context = page === 'channel' ? channelContext() : null;
|
||||||
if (items.length === 0) return;
|
|
||||||
for (const item of items) sent.set(item.videoId, item.vidiqScore !== 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 {
|
try {
|
||||||
chrome.runtime.sendMessage({ type: 'videos-seen', items }).catch(() => {});
|
chrome.runtime.sendMessage({ type: 'cards', items: batch.map(({ item }) => item) }).catch(() => {});
|
||||||
} catch {
|
} catch {
|
||||||
observer.disconnect(); // the extension was reloaded; this script is orphaned
|
observer.disconnect(); // the extension was reloaded; this script is orphaned
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (found.size > batch.length) scheduleScan();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleScan() {
|
function scheduleScan() {
|
||||||
|
|||||||
Reference in New Issue
Block a user