Outlier score badge on YouTube thumbnails

Scores every card against the median views of its own channel's last N
videos, badging the thumbnail above the duration stamp. Shorts and regular
videos are scored in separate pools, live streams in none. Baselines come
from YouTube's InnerTube endpoint, one request per channel, cached a day.
Interface in English and Turkish.
This commit is contained in:
2026-08-15 10:32:29 +03:00
commit 0d5a232f08
20 changed files with 2215 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
/* Kartın kapağının üstüne basılan rozet.
*
* Rozet kapağın kendi kapsayıcısına konur, karta değil: YouTube kartın alt
* kısmını (başlık, kanal satırı) sık sık yeniden çiziyor, kapak kutusu ise
* kaydırma boyunca yerinde kalıyor. Konumu süre damgasının hemen üstü.
*/
var OBBadge = (function () {
"use strict";
var P = OBParse;
var T = OBI18n;
var HOST_SELECTORS = [
"ytd-thumbnail",
"yt-thumbnail-view-model",
".ytThumbnailViewModelHost",
"a#thumbnail",
".shortsLockupViewModelHostThumbnailContainer"
];
function host(card) {
for (var i = 0; i < HOST_SELECTORS.length; i++) {
var el = card.querySelector(HOST_SELECTORS[i]);
if (el) return el;
}
return card;
}
function isShortCard(card) {
return /shorts-lockup/i.test(card.tagName) || !!card.querySelector('a[href*="/shorts/"]');
}
function tone(score) {
if (score >= 10) return "ob-t4";
if (score >= 5) return "ob-t3";
if (score >= 2) return "ob-t2";
if (score >= 1) return "ob-t1";
return "ob-t0";
}
function label(score) {
if (score >= 10) return Math.round(score) + "x";
return T.num(Math.round(score * 10) / 10, 1) + "x";
}
function element(card) {
var box = host(card);
var el = box.querySelector(":scope > .ob-badge");
if (!el) {
el = document.createElement("div");
el.className = "ob-badge";
if (getComputedStyle(box).position === "static") box.style.position = "relative";
box.appendChild(el);
}
/* Shorts kapağının altı izlenme metniyle dolu, rozet orada üste gider. */
el.dataset.short = isShortCard(card) ? "1" : "";
return el;
}
function base(el) {
return "ob-badge" + (el.dataset.short ? " ob-short" : "");
}
function loading(card) {
var el = element(card);
el.className = base(el) + " ob-loading";
el.textContent = "";
el.removeAttribute("title");
}
function show(card, result, extra) {
var el = element(card);
el.className = base(el) + " " + tone(result.score);
el.textContent = label(result.score);
var unit = T.t(extra && extra.isShort ? "unitShorts" : "unitVideos");
var lines = [
T.t("badgeScore", label(result.score)),
T.t("badgeViews", P.humanCount(result.views)),
T.t("badgeMedian", P.humanCount(result.median), result.sample, unit)
];
if (result.percentile != null) {
lines.push(T.t("badgeRank", Math.round(result.percentile * 100)));
}
if (extra && extra.young) {
lines.push(T.t("badgeYoung"));
el.className += " ob-young";
}
lines.push(T.t("badgeRounded"));
el.title = lines.join("\n");
}
function fail(card, reason) {
var el = element(card);
el.className = base(el) + " ob-none";
el.textContent = "-";
el.title = reason || T.t("badgeFailed");
}
function clear(card) {
var box = host(card);
var el = box.querySelector(":scope > .ob-badge");
if (el) el.remove();
}
return { loading: loading, show: show, fail: fail, clear: clear };
})();
+291
View File
@@ -0,0 +1,291 @@
/* Sayfadaki kartları bulur, puanlar, rozeti basar.
*
* Kartlar ancak ekrana girdiğinde işlenir (IntersectionObserver): ana sayfa
* yüzlerce kart oluşturuyor, hepsi için kanal baseline'ı çekmek hem gereksiz
* hem de YouTube'a karşı kaba olurdu. Aynı kanalın baseline'ı tek istekle
* alınır, günlük önbelleğe yazılır ve sonraki kartlar oradan okur.
*/
(function () {
"use strict";
var api = (typeof browser !== "undefined" ? browser : chrome);
var P = OBParse;
var T = OBI18n;
var CARD_SELECTOR = [
"ytd-rich-item-renderer",
"ytd-video-renderer",
"ytd-compact-video-renderer",
"ytd-grid-video-renderer",
"ytd-playlist-video-renderer",
"yt-lockup-view-model",
"ytm-shorts-lockup-view-model",
"ytm-shorts-lockup-view-model-v2"
].join(",");
var counters = { seen: 0, scored: 0, skipped: 0, failed: 0 };
var lastError = null;
var settings = null;
var observer = null;
/* --- Karttan veri çıkarma -------------------------------------------- */
function videoIdOf(card) {
var links = card.querySelectorAll('a[href*="/watch?v="], a[href*="/shorts/"]');
for (var i = 0; i < links.length; i++) {
var href = links[i].getAttribute("href") || "";
var m = /[?&]v=([\w-]{11})/.exec(href) || /\/shorts\/([\w-]{11})/.exec(href);
if (m) return m[1];
}
return null;
}
function isShortCard(card) {
if (/shorts-lockup/i.test(card.tagName)) return true;
return !!card.querySelector('a[href*="/shorts/"]');
}
function handleOf(card) {
var links = card.querySelectorAll('a[href^="/@"], a[href^="/channel/"], a[href*="youtube.com/@"]');
for (var i = 0; i < links.length; i++) {
var href = links[i].getAttribute("href") || "";
if (href.indexOf("/channel/") >= 0) {
var id = href.split("/channel/")[1].split(/[/?]/)[0];
if (/^UC[\w-]{22}$/.test(id)) return { channelId: id };
}
var m = /\/(@[\w.\-]+)/.exec(href);
if (m) return { handle: m[1] };
}
return null;
}
/* Kartın üzerindeki izlenme metni. Baseline'da bulunamayan videolar için son
* çare: kanalın son N videosuna girmeyen eski videolar böyle puanlanır.
* Metin kullanıcının arayüz dilinde geldiği için dil bilgisiyle okunur. */
function domViews(card) {
var lang = document.documentElement.lang || "en";
var nodes = card.querySelectorAll("span, .yt-core-attributed-string");
for (var i = 0; i < nodes.length; i++) {
var t = (nodes[i].textContent || "").trim();
if (!t || t.length > 40) continue;
if (/view|görüntüleme|goruntuleme|izlenme/i.test(t)) {
var n = P.parseCountUI(t, lang);
if (n) return n;
}
}
return null;
}
function domAgeDays(card) {
var nodes = card.querySelectorAll("span, .yt-core-attributed-string");
for (var i = 0; i < nodes.length; i++) {
var t = (nodes[i].textContent || "").trim();
if (!t || t.length > 40) continue;
if (/ago|önce|once/i.test(t)) {
var d = P.parseAgeDays(t);
if (d != null) return d;
}
}
return null;
}
/* --- Kanal kimliği --------------------------------------------------- */
/* Handle'dan kanal kimliğine çözüm kalıcıdır (değişmez), o yüzden TTL'siz
* saklanır. İzleme sayfasının yan listesinde kartta kanal bağlantısı hiç
* olmayabiliyor; orada son çare videonun kendi ucundan sorulur. */
function channelIdOf(card, videoId) {
var found = handleOf(card);
if (found && found.channelId) return Promise.resolve(found.channelId);
if (found && found.handle) {
return OBStore.readChannelId(found.handle).then(function (cached) {
if (cached) return cached;
return OBTube.resolveChannel(found.handle).then(function (id) {
OBStore.writeChannelId(found.handle, id);
return id;
});
});
}
var key = "v:" + videoId;
return OBStore.readChannelId(key).then(function (cached) {
if (cached) return cached;
return OBTube.videoDetails(videoId).then(function (d) {
if (d.channelId) OBStore.writeChannelId(key, d.channelId);
return d.channelId;
});
});
}
/* --- Kart işleme ----------------------------------------------------- */
function processCard(card) {
var videoId = videoIdOf(card);
if (!videoId) return;
if (card.obVideoId === videoId) return;
card.obVideoId = videoId;
var isShort = isShortCard(card);
if (isShort && !settings.scoreShorts) { OBBadge.clear(card); return; }
counters.seen++;
OBBadge.loading(card);
channelIdOf(card, videoId).then(function (channelId) {
if (!channelId) throw new Error("kanal bulunamadı");
return OBScore.baseline(channelId, isShort);
}).then(function (blob) {
if (card.obVideoId !== videoId) return; /* kart başka videoya geçti */
var own = OBScore.lookupViews(videoId, blob);
var views = own ? own.views : domViews(card);
var result = OBScore.score(videoId, views, isShort, blob);
if (result.score == null) {
counters.skipped++;
OBBadge.clear(card);
return;
}
if (settings.minScore && result.score < settings.minScore) {
OBBadge.clear(card);
return;
}
var age = own && own.age != null ? own.age : domAgeDays(card);
counters.scored++;
OBBadge.show(card, result, { young: age != null && age < 7, isShort: isShort });
}).catch(function (err) {
counters.failed++;
lastError = String(err && err.message ? err.message : err);
OBBadge.clear(card);
});
}
/* --- İzleme sayfası panosu ------------------------------------------- */
var panelVideoId = null;
function currentWatchId() {
var m = /[?&]v=([\w-]{11})/.exec(location.search);
return m ? m[1] : null;
}
function updatePanel() {
if (!settings || !settings.enabled || !settings.showPanel) { OBPanel.remove(); return; }
var videoId = currentWatchId();
if (!videoId) { panelVideoId = null; OBPanel.remove(); return; }
if (videoId === panelVideoId) return;
panelVideoId = videoId;
OBPanel.renderLoading();
var details = null;
OBTube.videoDetails(videoId).then(function (d) {
details = d;
if (!d.channelId) throw new Error("kanal bulunamadı");
OBStore.writeChannelId("v:" + videoId, d.channelId);
var isShort = d.lengthSeconds != null && d.lengthSeconds <= 60;
return OBScore.baseline(d.channelId, isShort).then(function (blob) {
return { blob: blob, isShort: isShort };
});
}).then(function (ctx) {
if (panelVideoId !== videoId) return;
var own = OBScore.lookupViews(videoId, ctx.blob);
var result = OBScore.score(videoId, details.views, ctx.isShort, ctx.blob);
if (result.score == null) {
OBPanel.renderError(T.t("panelNoScore", OBScore.MIN_SAMPLE));
return;
}
OBPanel.render({
videoId: videoId,
score: result.score,
views: result.views,
median: result.median,
sample: result.sample,
percentile: result.percentile,
ageDays: own ? own.age : null,
isShort: ctx.isShort,
blob: ctx.blob
});
}).catch(function (err) {
if (panelVideoId !== videoId) return;
lastError = String(err && err.message ? err.message : err);
OBPanel.renderError(T.t("panelError", lastError));
});
}
/* --- Tarama ---------------------------------------------------------- */
function scan() {
if (!settings || !settings.enabled) return;
var cards = document.querySelectorAll(CARD_SELECTOR);
for (var i = 0; i < cards.length; i++) {
var card = cards[i];
/* İç içe kartlarda (rich item içinde lockup) sadece en içteki işlenir. */
if (card.querySelector(CARD_SELECTOR)) continue;
if (!card.obObserved) {
card.obObserved = true;
observer.observe(card);
} else if (card.obVideoId && card.obVideoId !== videoIdOf(card)) {
/* YouTube kartları geri dönüştürüyor: aynı DOM elemanı başka videoya
* bağlıysa rozet eskimiş demektir. */
processCard(card);
}
}
}
var scanTimer = null;
function scheduleScan() {
if (scanTimer) return;
scanTimer = setTimeout(function () { scanTimer = null; scan(); }, 250);
}
function clearAll() {
var cards = document.querySelectorAll(CARD_SELECTOR);
for (var i = 0; i < cards.length; i++) {
OBBadge.clear(cards[i]);
cards[i].obVideoId = null;
}
OBPanel.remove();
panelVideoId = null;
}
function start() {
observer = new IntersectionObserver(function (entries) {
for (var i = 0; i < entries.length; i++) {
if (entries[i].isIntersecting) processCard(entries[i].target);
}
}, { rootMargin: "300px 0px" });
new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true });
window.addEventListener("yt-navigate-finish", function () {
scheduleScan();
updatePanel();
});
document.addEventListener("yt-page-data-updated", function () {
scheduleScan();
updatePanel();
});
scan();
updatePanel();
}
/* Ayar penceresinden gelen sorular. */
api.runtime.onMessage.addListener(function (msg) {
if (msg && msg.type === "ob-stats") {
return Promise.resolve({ counters: counters, lastError: lastError });
}
return undefined;
});
api.storage.onChanged.addListener(function (changes, area) {
if (area !== "local" || !changes.settings) return;
settings = Object.assign({}, OBStore.DEFAULTS, changes.settings.newValue || {});
T.init(settings.language);
clearAll();
counters = { seen: 0, scored: 0, skipped: 0, failed: 0 };
if (settings.enabled) { scan(); updatePanel(); }
});
OBStore.settings().then(function (s) {
settings = s;
T.init(s.language);
start();
});
})();
+160
View File
@@ -0,0 +1,160 @@
/* Arayüz metinleri. Dil tarayıcıdan gelir, ayarlardan elle de seçilebilir:
* Firefox'un dili İngilizce olup YouTube'u Türkçe kullanmak yaygın, ikisi
* çakıştığında karar kullanıcının olsun.
*
* Sayı biçimi de dile bağlı: Türkçede ondalık ayırıcı virgül ve kısaltmalar
* B/Mn/Mr, İngilizcede nokta ve K/M/B.
*/
var OBI18n = (function () {
"use strict";
var STRINGS = {
tr: {
badgeScore: "Outlier skoru $1",
badgeViews: "İzlenme: $1",
badgeMedian: "Kanal medyanı: $1 (son $2 $3)",
badgeRank: "Kanalın son videolarının %$1'inden yüksek",
badgeYoung: "Video 7 günden yeni: skor olduğundan düşük çıkar",
badgeRounded: "Kanal izlenmeleri yuvarlanmış gelir, skor yaklaşıktır",
badgeFailed: "Skor hesaplanamadı",
unitVideos: "video",
unitShorts: "Shorts",
panelLoading: "Outlier hesaplanıyor",
panelTitle: "Outlier",
panelHead: "kanal medyanının katı",
panelViews: "İzlenme",
panelMedian: "Kanal medyanı",
panelMedianValue: "$1 (son $2 $3)",
panelRank: "Sıralama",
panelRankValue: "son videoların %$1'inden yüksek",
panelDaily: "Günlük ortalama",
panelDailyValue: "$1 izlenme",
panelDistTitle: "Kanalın son videoları (eskiden yeniye)",
panelNoScore: "Skor hesaplanamadı: kanalın bu havuzunda en az $1 video gerekiyor.",
panelError: "Skor alınamadı: $1",
panelNote: "Kanal izlenmeleri YouTube tarafından yuvarlanmış gelir, skor yaklaşıktır. " +
"7 günden yeni videoların skoru olduğundan düşük çıkar.",
popupPermission: "Eklentinin YouTube'a istek atma izni yok, rozet çıkmaz.",
popupGrant: "İzin ver",
popupEnabled: "Rozetler açık",
popupScoreShorts: "Shorts da puanlansın",
popupShowPanel: "Video sayfasında panel",
popupBaselineSize: "Baseline boyutu",
popupMinScore: "Şu skorun altını gizle",
popupTtl: "Önbellek ömrü",
popupLanguage: "Dil",
popupClear: "Önbelleği temizle",
popupCleared: "Önbellek temizlendi.",
popupNote: "Skor = videonun izlenmesi / kanalın son N videosunun izlenme medyanı. " +
"Shorts kendi havuzunda puanlanır, canlı yayınlar hiçbir havuza girmez. " +
"İzlenmeler YouTube tarafından yuvarlanmış geldiği için skor yaklaşıktır, " +
"7 günden yeni videolarınki olduğundan düşük çıkar.",
popupStatsChannels: "$1 kanalın verisi önbellekte.",
popupStatsTab: "Bu sekmede $1 kart tarandı, $2 skorlandı",
popupStatsSkipped: ", $1 veri yetersiz",
popupStatsFailed: ", $1 hata",
popupLastError: "Son hata: $1",
optAuto: "Otomatik",
optLastN: "son $1 video",
optShowAll: "hepsini göster",
optHours: "$1 saat",
optDays: "$1 gün",
optWeek: "1 hafta"
},
en: {
badgeScore: "Outlier score $1",
badgeViews: "Views: $1",
badgeMedian: "Channel median: $1 (last $2 $3)",
badgeRank: "Higher than $1% of the channel's recent videos",
badgeYoung: "Video is under 7 days old: the score reads lower than it should",
badgeRounded: "Channel view counts come rounded, so the score is approximate",
badgeFailed: "Score unavailable",
unitVideos: "videos",
unitShorts: "Shorts",
panelLoading: "Calculating outlier score",
panelTitle: "Outlier",
panelHead: "of the channel median",
panelViews: "Views",
panelMedian: "Channel median",
panelMedianValue: "$1 (last $2 $3)",
panelRank: "Rank",
panelRankValue: "higher than $1% of recent videos",
panelDaily: "Daily average",
panelDailyValue: "$1 views",
panelDistTitle: "Channel's recent videos (oldest to newest)",
panelNoScore: "No score: this channel pool needs at least $1 videos.",
panelError: "Score unavailable: $1",
panelNote: "Channel view counts come rounded from YouTube, so the score is approximate. " +
"Videos under 7 days old score lower than they deserve.",
popupPermission: "The extension is not allowed to call YouTube, so no badges appear.",
popupGrant: "Grant access",
popupEnabled: "Badges on",
popupScoreShorts: "Score Shorts too",
popupShowPanel: "Panel on the watch page",
popupBaselineSize: "Baseline size",
popupMinScore: "Hide scores below",
popupTtl: "Cache lifetime",
popupLanguage: "Language",
popupClear: "Clear cache",
popupCleared: "Cache cleared.",
popupNote: "Score = the video's views divided by the median views of the channel's last N " +
"videos. Shorts are scored in their own pool, live streams in none. View counts come " +
"rounded from YouTube so the score is approximate, and videos under a week old read low.",
popupStatsChannels: "$1 channels cached.",
popupStatsTab: "This tab: $1 cards scanned, $2 scored",
popupStatsSkipped: ", $1 lacked data",
popupStatsFailed: ", $1 failed",
popupLastError: "Last error: $1",
optAuto: "Automatic",
optLastN: "last $1 videos",
optShowAll: "show all",
optHours: "$1 hours",
optDays: "$1 days",
optWeek: "1 week"
}
};
var current = null;
function detect() {
var nav = (navigator.language || "en").toLowerCase();
return nav.indexOf("tr") === 0 ? "tr" : "en";
}
/* setting: "auto" | "tr" | "en" */
function init(setting) {
current = (setting === "tr" || setting === "en") ? setting : detect();
return current;
}
function lang() {
if (!current) current = detect();
return current;
}
function t(key) {
var table = STRINGS[lang()] || STRINGS.en;
var text = table[key] != null ? table[key] : (STRINGS.en[key] != null ? STRINGS.en[key] : key);
for (var i = 1; i < arguments.length; i++) {
text = text.split("$" + i).join(String(arguments[i]));
}
return text;
}
/* Ondalık ayırıcı dile göre: 2,4x / 2.4x */
function num(value, digits) {
var text = value.toFixed(digits == null ? 1 : digits);
return lang() === "tr" ? text.replace(".", ",") : text;
}
return { init: init, lang: lang, t: t, num: num };
})();
+320
View File
@@ -0,0 +1,320 @@
/* YouTube'un dahili (InnerTube) API'sine karşı istemci.
*
* İstekler hep hl=en/gl=US gider: izlenme kısaltmaları ("1.2M") ve yaş metni
* ("3 months ago") tek bir dilde ayrıştırılsın diye. Kullanıcının arayüz dili
* ne olursa olsun burası İngilizce okur.
*
* Oturum bilgisi gönderilmez (credentials: "omit"): rozet hesabının kullanıcının
* YouTube hesabıyla ilişkilendirilmesi için bir sebep yok.
*
* Bu resmi olmayan bir arayüz. Ayrıştıran her fonksiyon eksik alanlara karşı
* savunmacı yazıldı; yapı değişirse sonuç boş döner, sayfa bozulmaz.
*/
var OBTube = (function () {
"use strict";
var P = OBParse;
var BASE = "https://www.youtube.com/youtubei/v1";
var FALLBACK_VERSION = "2.20240701.00.00";
var cachedVersion = null;
/* Kanal sekmelerinin protobuf `params` değerleri. */
var CHANNEL_TABS = {
videos: "EgZ2aWRlb3PyBgQKAjoA",
shorts: "EgZzaG9ydHPyBgUKA5oBAA",
live: "EgdzdHJlYW1z8gYECgJ6AA"
};
var TAB_TITLES = { videos: "videos", shorts: "shorts", live: "live" };
var MAX_ROUNDS = 6;
function clientVersion() {
if (cachedVersion) return cachedVersion;
var scripts = document.querySelectorAll("script");
for (var i = 0; i < scripts.length; i++) {
var t = scripts[i].textContent;
if (!t || t.indexOf("INNERTUBE_CLIENT_VERSION") < 0) continue;
var m = /"INNERTUBE_CLIENT_VERSION":"([^"]+)"/.exec(t);
if (m) { cachedVersion = m[1]; return cachedVersion; }
}
cachedVersion = FALLBACK_VERSION;
return cachedVersion;
}
function context() {
return {
client: {
clientName: "WEB",
clientVersion: clientVersion(),
hl: "en",
gl: "US"
}
};
}
/* İstek iki yoldan gidebilir. Önce içerik betiğinin kendi fetch'i denenir:
* orada Origin başlığı moz-extension:// olur ve YouTube bunu reddedebilir.
* Reddederse Firefox'un içerik betiklerine verdiği `content` nesnesine
* düşeriz; oradaki fetch sayfanın kendi prensibiyle, yani youtube.com
* origin'iyle gider. Hangisinin çalıştığı ilk denemede öğrenilir ve öyle
* kalır, her istekte iki kez denenmez. */
var mode = null;
function pageFetch() {
return (typeof content !== "undefined" && content && content.fetch)
? content.fetch.bind(content) : null;
}
function request(url, init) {
var direct = function () {
return fetch(url, init).then(function (resp) {
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.json();
});
};
var viaPage = function () {
var f = pageFetch();
if (!f) return Promise.reject(new Error("sayfa fetch'i yok"));
/* json() yerine text(): yanıt nesnesi sayfa dünyasından geldiği için
* çözülmüş JSON'a Xray üzerinden erişmek sorun çıkarabiliyor, düz metin
* çıkarmıyor. */
return f(url, init).then(function (resp) {
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.text();
}).then(function (text) {
return JSON.parse(text);
});
};
if (mode === "page") return viaPage();
return direct().then(function (data) {
mode = "ext";
return data;
}, function (err) {
if (mode || !pageFetch()) throw err;
return viaPage().then(function (data) {
mode = "page";
return data;
});
});
}
function post(endpoint, body) {
var payload = Object.assign({}, body, { context: context() });
return request(BASE + "/" + endpoint + "?prettyPrint=false", {
method: "POST",
credentials: "omit",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).catch(function (err) {
throw new Error(endpoint + ": " + err.message);
});
}
/* --- Yanıt ayrıştırma ------------------------------------------------ */
/* Kanal sekmelerinde kullanılan 'lockupViewModel' biçimi. */
function parseLockup(lv) {
if (lv.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO") return null;
var vid = lv.contentId;
if (!vid) return null;
var meta = (lv.metadata && lv.metadata.lockupMetadataViewModel) || {};
var rows = P.findFirst(meta, "metadataRows") || [];
var parts = [];
for (var i = 0; i < rows.length; i++) {
var mp = rows[i].metadataParts || [];
for (var j = 0; j < mp.length; j++) parts.push(P.textOf(mp[j].text));
}
var views = null;
for (var k = 0; k < parts.length; k++) {
if (parts[k].toLowerCase().indexOf("view") >= 0) { views = P.parseCountEn(parts[k]); break; }
}
if (views == null) {
/* İzlenme metni yoksa yaş metnini izlenme sanmayalım: "2 months ago"
* bir sayı değil. */
for (var n = 0; n < parts.length; n++) {
if (parts[n] && P.parseAgeDays(parts[n]) == null) { views = P.parseCountEn(parts[n]); break; }
}
}
var published = "";
var watching = false;
for (var q = 0; q < parts.length; q++) {
var low = parts[q].toLowerCase();
if (low.indexOf("ago") >= 0) published = parts[q];
/* Süren canlı yayın "12K watching" der; bu izlenme değil anlık izleyici
* sayısıdır ve baseline'a girerse medyanı bozar. */
if (low.indexOf("watching") >= 0) watching = true;
}
if (watching) views = null;
var badges = P.findAll(lv, "badgeViewModel");
var membersOnly = badges.some(function (b) {
return b && b.badgeStyle === "BADGE_MEMBERS_ONLY";
});
var durationBadge = P.findFirst(lv, "thumbnailBadgeViewModel") || {};
return {
videoId: vid,
title: P.textOf(meta.title),
views: views,
publishedText: published,
ageDays: P.parseAgeDays(published),
durationSeconds: P.parseDuration(durationBadge.text || ""),
membersOnly: membersOnly,
/* Bitmiş canlı yayınlar Videos sekmesinde de görünür ve izlenmeleri
* normal videolarla kıyaslanamaz. YouTube bunları "Streamed 2 months
* ago" diye işaretliyor, ayrı bir istek atmadan ayıklıyoruz. */
wasLive: /streamed/i.test(published)
};
}
/* Shorts sekmesinin 'shortsLockupViewModel' biçimi. Yayın tarihi içermez. */
function parseShortsLockup(sl) {
var entity = sl.entityId || "";
var prefix = "shorts-shelf-item-";
/* Video kimliğinin kendisi de tire içerebilir ("Ab3-xc"), o yüzden sondan
* değil baştan kesiliyor. */
var vid = entity.indexOf(prefix) === 0 ? entity.slice(prefix.length) : null;
if (!vid) vid = P.findFirst(sl, "videoId");
if (!vid) return null;
var meta = sl.overlayMetadata || {};
return {
videoId: vid,
title: P.textOf(meta.primaryText),
views: P.parseCountEn(P.textOf(meta.secondaryText)),
publishedText: "",
ageDays: null,
durationSeconds: null,
membersOnly: false,
wasLive: false,
isShort: true
};
}
function parseVideoRenderer(vr) {
var vid = vr.videoId;
if (!vid) return null;
var published = P.textOf(vr.publishedTimeText);
var lengthText = P.textOf(vr.lengthText);
return {
videoId: vid,
title: P.textOf(vr.title),
views: P.parseCountEn(P.textOf(vr.viewCountText)),
publishedText: published,
ageDays: P.parseAgeDays(published),
durationSeconds: P.parseDuration(lengthText),
membersOnly: false,
wasLive: /streamed/i.test(published),
isLive: !!P.findFirst(vr, "isLiveNow")
};
}
function continuationToken(data) {
var tokens = P.findAll(data, "continuationCommand");
for (var i = 0; i < tokens.length; i++) {
if (tokens[i] && tokens[i].token) return tokens[i].token;
}
return null;
}
function selectedTab(data) {
var tabs = P.findAll(data, "tabRenderer");
for (var i = 0; i < tabs.length; i++) {
if (tabs[i] && tabs[i].selected) return P.textOf(tabs[i].title).trim().toLowerCase();
}
return "";
}
function collect(data, videos, seen) {
var kinds = [
["lockupViewModel", parseLockup],
["shortsLockupViewModel", parseShortsLockup],
["videoRenderer", parseVideoRenderer]
];
for (var i = 0; i < kinds.length; i++) {
var nodes = P.findAll(data, kinds[i][0]);
for (var j = 0; j < nodes.length; j++) {
var item = kinds[i][1](nodes[j]);
if (item && !seen[item.videoId]) {
seen[item.videoId] = true;
videos.push(item);
}
}
}
}
/* Bir kanal sekmesindeki videolar, en yeniden eskiye, en fazla `limit` tane.
* Kanalda o sekme yoksa YouTube sessizce başka bir sekme döndürdüğü için
* seçili sekmenin başlığı doğrulanır; uymuyorsa boş liste döner. */
function channelTab(channelId, tab, limit) {
var params = CHANNEL_TABS[tab];
if (!params) return Promise.reject(new Error("bilinmeyen sekme: " + tab));
var videos = [], seen = {}, tokens = {};
function round(data) {
if (!videos.length) {
var sel = selectedTab(data);
if (sel && sel.indexOf(TAB_TITLES[tab]) < 0) return videos;
}
var before = videos.length;
collect(data, videos, seen);
var token = continuationToken(data);
/* İlerleme yoksa ya da YouTube aynı token'ı tekrar veriyorsa dur:
* aksi halde bazı kanallarda sonsuz döngüye giriliyor. */
if (videos.length >= limit || !token || tokens[token] || videos.length === before) {
return videos.slice(0, limit);
}
tokens[token] = true;
if (Object.keys(tokens).length > MAX_ROUNDS) return videos.slice(0, limit);
return post("browse", { continuation: token }).then(round);
}
return post("browse", { browseId: channelId, params: params }).then(round);
}
/* @handle veya kanal adresini UC... kimliğine çevirir. */
function resolveChannel(raw) {
raw = (raw || "").trim();
if (!raw) return Promise.reject(new Error("boş kanal adresi"));
if (/^UC[\w-]{22}$/.test(raw)) return Promise.resolve(raw);
if (raw.indexOf("/channel/") >= 0) {
var tail = raw.split("/channel/")[1].split("/")[0].split("?")[0];
if (tail.indexOf("UC") === 0) return Promise.resolve(tail);
}
var url = raw;
if (url.indexOf("http") !== 0) {
url = "https://www.youtube.com/" + (url.charAt(0) === "@" ? url : "@" + url.replace(/^\/+/, ""));
}
return post("navigation/resolve_url", { url: url }).then(function (data) {
var endpoint = P.findFirst(data, "browseEndpoint") || {};
var id = endpoint.browseId || "";
if (id.indexOf("UC") === 0) return id;
/* Eski /user/ adresi olan kanallarda YouTube browseEndpoint yerine
* urlEndpoint ile başka bir adrese yolluyor. */
var next = (P.findFirst(data, "urlEndpoint") || {}).url || "";
if (next.indexOf("/channel/") >= 0) {
var t = next.split("/channel/")[1].split("/")[0].split("?")[0];
if (t.indexOf("UC") === 0) return t;
}
throw new Error("kanal çözülemedi: " + raw);
});
}
/* Videonun kanal kimliği ve tam izlenme sayısı. Kartta kanal bağlantısı
* bulunamadığında son çare olarak kullanılır: video başına bir istek. */
function videoDetails(videoId) {
return post("player", { videoId: videoId }).then(function (data) {
var d = data.videoDetails || {};
return {
channelId: d.channelId || null,
views: d.viewCount ? parseInt(d.viewCount, 10) : null,
title: d.title || "",
lengthSeconds: d.lengthSeconds ? parseInt(d.lengthSeconds, 10) : null,
isLive: !!d.isLiveContent
};
});
}
return {
channelTab: channelTab,
resolveChannel: resolveChannel,
videoDetails: videoDetails,
clientVersion: clientVersion
};
})();
+148
View File
@@ -0,0 +1,148 @@
/* İzleme sayfasındaki panel.
*
* Zaman içinde izlenme grafiği bilerek yok: YouTube hiçbir video için geçmiş
* izlenme eğrisi vermiyor, ne public API'de ne dahili uçlarda. vidIQ ve Social
* Blade dahil herkes o eğriyi kendi topladığı anlık ölçümlerden üretiyor, yani
* tek kullanıcılık bir eklentide eğri ancak aylar sonra ve sadece senin açtığın
* videolar için oluşurdu. Onun yerine burada beklemeden işe yarayan şey var:
* videonun kanal dağılımındaki yeri ve günlük izlenme hızı.
*/
var OBPanel = (function () {
"use strict";
var P = OBParse;
var T = OBI18n;
var SVG = "http://www.w3.org/2000/svg";
function el(tag, cls, text) {
var node = document.createElement(tag);
if (cls) node.className = cls;
if (text != null) node.textContent = text;
return node;
}
function svgEl(tag, attrs) {
var node = document.createElementNS(SVG, tag);
for (var k in attrs) node.setAttribute(k, attrs[k]);
return node;
}
/* Kanalın son videolarının izlenme dağılımı, eskiden yeniye.
* Bu video vurgulu, medyan kesikli çizgi. */
function distribution(pool, videoId, median) {
var items = pool.slice().reverse();
var w = 100, h = 46;
var svg = svgEl("svg", { viewBox: "0 0 " + w + " " + h, class: "ob-chart", preserveAspectRatio: "none" });
var max = 0;
for (var i = 0; i < items.length; i++) max = Math.max(max, items[i].views || 0);
if (!max) return svg;
var bw = w / items.length;
for (var j = 0; j < items.length; j++) {
var v = items[j].views || 0;
var bh = Math.max(1, (v / max) * (h - 2));
var bar = svgEl("rect", {
x: (j * bw + bw * 0.12).toFixed(2),
y: (h - bh).toFixed(2),
width: (bw * 0.76).toFixed(2),
height: bh.toFixed(2),
class: items[j].id === videoId ? "ob-bar ob-bar-self" : "ob-bar"
});
var tip = svgEl("title", {});
tip.textContent = P.humanCount(v);
bar.appendChild(tip);
svg.appendChild(bar);
}
if (median) {
var y = h - (median / max) * (h - 2);
svg.appendChild(svgEl("line", {
x1: 0, x2: w, y1: y.toFixed(2), y2: y.toFixed(2), class: "ob-median"
}));
}
return svg;
}
function row(label, value) {
var r = el("div", "ob-row");
r.appendChild(el("span", "ob-row-label", label));
r.appendChild(el("span", "ob-row-value", value));
return r;
}
function mount() {
var anchor = document.querySelector("ytd-watch-metadata #above-the-fold")
|| document.querySelector("ytd-watch-metadata")
|| document.querySelector("#secondary-inner");
if (!anchor) return null;
var existing = document.getElementById("ob-panel");
if (existing && existing.parentElement === anchor) return existing;
if (existing) existing.remove();
var panel = el("div", "ob-panel");
panel.id = "ob-panel";
anchor.appendChild(panel);
return panel;
}
function remove() {
var existing = document.getElementById("ob-panel");
if (existing) existing.remove();
}
function renderLoading() {
var panel = mount();
if (!panel) return;
panel.textContent = "";
panel.appendChild(el("div", "ob-panel-head", T.t("panelLoading")));
}
function renderError(message) {
var panel = mount();
if (!panel) return;
panel.textContent = "";
panel.appendChild(el("div", "ob-panel-head", T.t("panelTitle")));
panel.appendChild(el("div", "ob-note", message));
}
function tone(score) {
return score >= 10 ? "ob-t4" : score >= 5 ? "ob-t3"
: score >= 2 ? "ob-t2" : score >= 1 ? "ob-t1" : "ob-t0";
}
function render(data) {
var panel = mount();
if (!panel) return;
panel.textContent = "";
var head = el("div", "ob-panel-head");
var scoreText = data.score >= 10
? Math.round(data.score) + "x"
: T.num(Math.round(data.score * 10) / 10, 1) + "x";
head.appendChild(el("span", "ob-chip " + tone(data.score), scoreText));
head.appendChild(el("span", "ob-head-text", T.t("panelHead")));
panel.appendChild(head);
var unit = T.t(data.isShort ? "unitShorts" : "unitVideos");
var body = el("div", "ob-panel-body");
body.appendChild(row(T.t("panelViews"), P.humanCount(data.views)));
body.appendChild(row(T.t("panelMedian"),
T.t("panelMedianValue", P.humanCount(data.median), data.sample, unit)));
if (data.percentile != null) {
body.appendChild(row(T.t("panelRank"),
T.t("panelRankValue", Math.round(data.percentile * 100))));
}
if (data.ageDays) {
body.appendChild(row(T.t("panelDaily"),
T.t("panelDailyValue", P.humanCount(Math.round(data.views / Math.max(1, data.ageDays))))));
}
panel.appendChild(body);
var pool = (data.isShort ? data.blob.short : data.blob.video) || [];
if (pool.length) {
panel.appendChild(el("div", "ob-section-title", T.t("panelDistTitle")));
panel.appendChild(distribution(pool, data.videoId, data.median));
}
panel.appendChild(el("div", "ob-note", T.t("panelNote")));
}
return { render: render, renderLoading: renderLoading, renderError: renderError, remove: remove };
})();
+147
View File
@@ -0,0 +1,147 @@
/* Metin ve JSON ayrıştırma yardımcıları.
*
* İki ayrı sayı ayrıştırıcısı var ve bu bilerek böyle: InnerTube yanıtlarını
* hep hl=en isteyip okuyoruz, orada "B" milyar demek. Sayfanın kendi DOM'u ise
* kullanıcının arayüz dilinde ve Türkçe arayüzde "B" bin demek. Aynı fonksiyonla
* ikisini okumak 24 bin izlenmeyi 24 milyar yapardı.
*/
var OBParse = (function () {
"use strict";
function findAll(obj, key, out) {
out = out || [];
if (Array.isArray(obj)) {
for (var i = 0; i < obj.length; i++) findAll(obj[i], key, out);
} else if (obj && typeof obj === "object") {
for (var k in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, k)) continue;
if (k === key) out.push(obj[k]);
else findAll(obj[k], key, out);
}
}
return out;
}
function findFirst(obj, key, fallback) {
var hits = findAll(obj, key);
return hits.length ? hits[0] : fallback;
}
/* simpleText / runs / content biçimlerinin hepsini düz metne çevirir. */
function textOf(node) {
if (node == null) return "";
if (typeof node === "string") return node;
if (typeof node !== "object") return "";
if (typeof node.simpleText === "string") return node.simpleText;
if (typeof node.content === "string") return node.content;
if (Array.isArray(node.runs)) {
return node.runs.map(function (r) { return r.text || ""; }).join("");
}
return "";
}
/* "1.2M views" / "341,331 views" -> sayı. İngilizce kısaltmalara göre.
* Kısaltma harfi bir kelimenin başı olmamalı: "2 months ago" içindeki 'm',
* yoksa milyon çarpanı sanılıp 2'yi 2.000.000 yapardı. */
var EN_MULT = { k: 1e3, m: 1e6, b: 1e9 };
function parseCountEn(text) {
if (!text) return null;
var m = /([\d.,]+)\s*([KMB](?![A-Za-z]))?/i.exec(String(text).replace(/ /g, " "));
if (!m) return null;
var num = m[1];
var suffix = (m[2] || "").toLowerCase();
if (suffix) {
/* Kısaltılmış biçimde ayırıcı ondalık noktasıdır (1.2M, 1,2 M). */
var value = parseFloat(num.replace(/,/g, "."));
return isNaN(value) ? null : Math.round(value * EN_MULT[suffix]);
}
var digits = num.replace(/[.,\s]/g, "");
return /^\d+$/.test(digits) ? parseInt(digits, 10) : null;
}
/* Sayfanın kendi metnini arayüz diline göre okur.
* Türkçe: 24 B = 24.000, 1,3 Mn = 1.300.000, 2,1 Mr = 2.100.000.000 */
var TR_MULT = { b: 1e3, bin: 1e3, mn: 1e6, mr: 1e9 };
function parseCountUI(text, lang) {
if (!text) return null;
var s = String(text).replace(/ /g, " ");
var turkish = (lang || "").toLowerCase().indexOf("tr") === 0;
if (!turkish) return parseCountEn(s);
var m = /([\d.,]+)\s*(Mn|Mr|bin|B)(?![a-zA-ZğüşöçıİĞÜŞÖÇ])/i.exec(s);
if (m) {
var value = parseFloat(m[1].replace(/\./g, "").replace(/,/g, "."));
var mult = TR_MULT[m[2].toLowerCase()];
return isNaN(value) || !mult ? null : Math.round(value * mult);
}
var plain = /([\d.,]+)/.exec(s);
if (!plain) return null;
var digits = plain[1].replace(/[.,\s]/g, "");
return /^\d+$/.test(digits) ? parseInt(digits, 10) : null;
}
/* "3 months ago" -> 91,3 gün. Türkçe arayüz metinleri de tanınır. */
var AGE_UNITS = {
second: 1 / 86400, minute: 1 / 1440, hour: 1 / 24,
day: 1, week: 7, month: 30.44, year: 365.25,
saniye: 1 / 86400, dakika: 1 / 1440, saat: 1 / 24,
gun: 1, hafta: 7, ay: 30.44, yil: 365.25
};
function parseAgeDays(text) {
if (!text) return null;
/* Türkçe harfler burada ASCII'ye indirgeniyor, çünkü karşılaştırılan şey
* kullanıcıya gösterilen metin değil, sözlükteki anahtar. */
var s = String(text).toLowerCase()
.replace(/ü/g, "u").replace(/ı/g, "i").replace(/ğ/g, "g")
.replace(/ş/g, "s").replace(/ö/g, "o").replace(/ç/g, "c");
var m = /(\d+)\s+(second|minute|hour|day|week|month|year|saniye|dakika|saat|gun|hafta|ay|yil)/.exec(s);
if (!m) return null;
return parseInt(m[1], 10) * AGE_UNITS[m[2]];
}
/* "9:46" -> 586 saniye. */
function parseDuration(text) {
if (!text) return null;
var parts = String(text).trim().split(":");
var seconds = 0;
for (var i = 0; i < parts.length; i++) {
if (!/^\d+$/.test(parts[i])) return null;
seconds = seconds * 60 + parseInt(parts[i], 10);
}
return parts.length ? seconds : null;
}
function median(values) {
if (!values.length) return null;
var s = values.slice().sort(function (a, b) { return a - b; });
var mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
/* 1234567 -> "1,2 Mn" (tr) veya "1.2M" (en). Arayüzde yer dar. */
function humanCount(n) {
if (n == null) return "-";
var tr = typeof OBI18n === "undefined" || OBI18n.lang() === "tr";
var fmt = function (x, unit) {
var text = x.toFixed(1).replace(/\.0$/, "");
return tr ? text.replace(".", ",") + " " + unit : text + unit;
};
var units = tr ? { b: "Mr", m: "Mn", k: "B" } : { b: "B", m: "M", k: "K" };
if (n >= 1e9) return fmt(n / 1e9, units.b);
if (n >= 1e6) return fmt(n / 1e6, units.m);
if (n >= 1e5) return tr ? Math.round(n / 1e3) + " " + units.k : Math.round(n / 1e3) + units.k;
if (n >= 1e3) return fmt(n / 1e3, units.k);
return String(Math.round(n));
}
return {
findAll: findAll,
findFirst: findFirst,
textOf: textOf,
parseCountEn: parseCountEn,
parseCountUI: parseCountUI,
parseAgeDays: parseAgeDays,
parseDuration: parseDuration,
median: median,
humanCount: humanCount
};
})();
+159
View File
@@ -0,0 +1,159 @@
/* Outlier skoru: videonun izlenmesi, kendi kanalının normalinin kaç katı.
*
* Skor = video izlenmesi / kanalın son N videosunun izlenme MEDYANI.
* Medyan bilerek seçildi: tek bir viral video ortalamayı yukarı çekip aynı
* kanalın diğer videolarını "outlier değil" gösterirdi.
*
* Shorts ve normal videolar ayrı havuzlarda puanlanır, çünkü izlenme
* dağılımları birbirine hiç benzemez. Canlı yayınlar hiçbir havuza girmez.
*
* BİLİNEN SINIRLAR (arayüzde de yazar):
* - Kanal izlenmeleri YouTube tarafından yuvarlanmış gelir ("125K views"),
* skor yaklaşık bir orandır.
* - Çok yeni videolar henüz izlenme toplamamıştır, skorları düşük çıkar.
* - Baseline kanalın son N videosudur, tüm geçmişi değil.
* - Havuzda 3'ten az video varsa skor hesaplanmaz.
*/
var OBScore = (function () {
"use strict";
var P = OBParse;
var MIN_SAMPLE = 3;
var MAX_PARALLEL = 4;
var inflight = {}; /* kanal kimliği -> devam eden istek */
var running = 0;
var queue = [];
function schedule(task) {
return new Promise(function (resolve, reject) {
queue.push({ task: task, resolve: resolve, reject: reject });
pump();
});
}
function pump() {
while (running < MAX_PARALLEL && queue.length) {
var job = queue.shift();
running++;
job.task().then(job.resolve, job.reject).then(function () {
running--;
pump();
});
}
}
function slim(items) {
var out = [];
for (var i = 0; i < items.length; i++) {
var v = items[i];
/* Üyelere özel videoların izlenmesi dışarıya hiç verilmiyor: ne
* baseline'a girerler ne de puanlanabilirler. */
if (v.membersOnly || !v.views) continue;
out.push({
id: v.videoId,
views: v.views,
age: v.ageDays == null ? null : Math.round(v.ageDays * 10) / 10
});
}
return out;
}
function fetchVideos(channelId, size) {
return OBTube.channelTab(channelId, "videos", size).then(function (items) {
/* Bitmiş canlı yayınlar Videos sekmesinde de listelenir; izlenmeleri
* normal videolarla aynı havuzda ölçülemez. */
return slim(items.filter(function (v) { return !v.wasLive && !v.isLive; }));
});
}
function fetchShorts(channelId, size) {
return OBTube.channelTab(channelId, "shorts", size).then(slim);
}
/* Kanalın baseline'ını döndürür; önbellekte varsa oradan.
* Shorts havuzu sadece bir Shorts puanlanacaksa çekilir: ana sayfadaki
* kartların çoğu normal video ve bu, kanal başına isteği yarıya indirir. */
function baseline(channelId, needShorts) {
var key = channelId + (needShorts ? ":s" : "");
if (inflight[key]) return inflight[key];
var promise = OBStore.settings().then(function (s) {
return OBStore.readBaseline(channelId, s.baselineSize, s.ttlHours).then(function (cached) {
if (cached && (!needShorts || cached.short)) return cached;
return schedule(function () {
var jobs = [];
if (cached) {
jobs.push(Promise.resolve(cached.video));
} else {
jobs.push(fetchVideos(channelId, s.baselineSize).catch(function () { return []; }));
}
jobs.push(needShorts
? fetchShorts(channelId, s.baselineSize).catch(function () { return []; })
: Promise.resolve(cached ? cached.short : null));
return Promise.all(jobs).then(function (res) {
var blob = {
v: OBStore.CACHE_VERSION,
fetchedAt: cached ? cached.fetchedAt : Date.now(),
size: s.baselineSize,
video: res[0],
short: res[1]
};
OBStore.writeBaseline(channelId, blob);
return blob;
});
});
});
});
inflight[key] = promise;
promise.catch(function () {}).then(function () { delete inflight[key]; });
return promise;
}
/* Videoyu kendi türündeki havuza göre puanlar, kendisini havuzdan çıkararak:
* bir video kendi medyanını yukarı çekerse skoru olduğundan düşük çıkardı. */
function score(videoId, views, isShort, blob) {
var pool = (isShort ? blob.short : blob.video) || [];
var others = [];
var own = null;
for (var i = 0; i < pool.length; i++) {
if (pool[i].id === videoId) { own = pool[i]; continue; }
if (pool[i].views) others.push(pool[i].views);
}
if (views == null && own) views = own.views;
if (others.length < MIN_SAMPLE || !views) {
return { score: null, median: null, sample: others.length, views: views };
}
var med = P.median(others);
if (!med) return { score: null, median: null, sample: others.length, views: views };
return {
score: views / med,
median: med,
sample: others.length,
views: views,
ageDays: own ? own.age : null,
/* Kanalın son N videosunun kaçından daha çok izlenmiş. */
percentile: others.filter(function (v) { return v < views; }).length / others.length
};
}
function lookupViews(videoId, blob) {
var pools = [blob.video || [], blob.short || []];
for (var p = 0; p < pools.length; p++) {
for (var i = 0; i < pools[p].length; i++) {
if (pools[p][i].id === videoId) return pools[p][i];
}
}
return null;
}
return {
MIN_SAMPLE: MIN_SAMPLE,
baseline: baseline,
score: score,
lookupViews: lookupViews
};
})();
+144
View File
@@ -0,0 +1,144 @@
/* Kalıcı depo: ayarlar, kanal baseline önbelleği ve handle -> kanal kimliği
* eşleşmesi.
*
* Baseline önbelleği olmadan ana sayfada aşağı her kaydırmada onlarca kanal
* için yeniden istek atılırdı. Sayfa ömrü boyunca ayrıca bellekte tutulur,
* aynı kanal için aynı oturumda depoya bir kez gidilir.
*/
var OBStore = (function () {
"use strict";
var api = (typeof browser !== "undefined" ? browser : chrome);
var local = api.storage.local;
var CACHE_VERSION = 1;
var MAX_CHANNELS = 400; /* aşılınca en eski baseline'lar silinir */
var DEFAULTS = {
enabled: true,
baselineSize: 30, /* kanalın son kaç videosu medyanı oluşturur */
ttlHours: 24,
minScore: 0, /* altındaki skorlar rozet basmaz (0 = hepsi) */
scoreShorts: true,
showPanel: true,
language: "auto" /* auto | tr | en */
};
var mem = {}; /* anahtar -> değer, sayfa ömrü boyunca */
var settingsCache = null;
function get(keys) {
return local.get(keys);
}
function set(obj) {
return local.set(obj);
}
function settings() {
if (settingsCache) return Promise.resolve(settingsCache);
return get("settings").then(function (r) {
settingsCache = Object.assign({}, DEFAULTS, r.settings || {});
return settingsCache;
});
}
function saveSettings(patch) {
return settings().then(function (s) {
settingsCache = Object.assign({}, s, patch);
return set({ settings: settingsCache });
});
}
/* --- Kanal baseline'ı ------------------------------------------------ */
function baselineKey(channelId) { return "bl:" + channelId; }
function readBaseline(channelId, size, ttlHours) {
var key = baselineKey(channelId);
var fresh = function (blob) {
if (!blob || blob.v !== CACHE_VERSION || blob.size !== size) return null;
if (Date.now() - blob.fetchedAt > ttlHours * 3600 * 1000) return null;
return blob;
};
if (mem[key] !== undefined) return Promise.resolve(fresh(mem[key]));
return get(key).then(function (r) {
mem[key] = r[key] || null;
return fresh(mem[key]);
});
}
function writeBaseline(channelId, blob) {
var key = baselineKey(channelId);
mem[key] = blob;
var payload = {};
payload[key] = blob;
return set(payload).then(function () { return touchIndex(channelId); });
}
/* Silinecekleri seçebilmek için kanal başına son yazma zamanı tutulur.
* storage.local'ın tamamını okuyup taramak yerine tek bir dizin anahtarı. */
function touchIndex(channelId) {
return get("blIndex").then(function (r) {
var index = r.blIndex || {};
index[channelId] = Date.now();
var ids = Object.keys(index);
if (ids.length > MAX_CHANNELS) {
ids.sort(function (a, b) { return index[a] - index[b]; });
var drop = ids.slice(0, ids.length - MAX_CHANNELS + 40);
for (var i = 0; i < drop.length; i++) {
delete index[drop[i]];
delete mem[baselineKey(drop[i])];
}
local.remove(drop.map(baselineKey));
}
return set({ blIndex: index });
});
}
/* --- handle -> kanal kimliği ----------------------------------------- */
function readChannelId(handle) {
var key = "ch:" + handle.toLowerCase();
if (mem[key] !== undefined) return Promise.resolve(mem[key]);
return get(key).then(function (r) {
mem[key] = r[key] || null;
return mem[key];
});
}
function writeChannelId(handle, channelId) {
var key = "ch:" + handle.toLowerCase();
mem[key] = channelId;
var payload = {};
payload[key] = channelId;
return set(payload);
}
function clearBaselines() {
mem = {};
return get("blIndex").then(function (r) {
var ids = Object.keys(r.blIndex || {});
return local.remove(ids.map(baselineKey).concat(["blIndex"]));
});
}
function stats() {
return get("blIndex").then(function (r) {
return { channels: Object.keys(r.blIndex || {}).length };
});
}
return {
DEFAULTS: DEFAULTS,
CACHE_VERSION: CACHE_VERSION,
settings: settings,
saveSettings: saveSettings,
readBaseline: readBaseline,
writeBaseline: writeBaseline,
readChannelId: readChannelId,
writeChannelId: writeChannelId,
clearBaselines: clearBaselines,
stats: stats
};
})();
+124
View File
@@ -0,0 +1,124 @@
/* Rozet kapağın üstünde durduğu için renkleri YouTube temasından bağımsız:
koyu zemin, beyaz yazı, her iki temada da okunur. Panel ise sayfanın
içinde olduğundan YouTube'un kendi tema değişkenlerini kullanır. */
/* Süre damgasının hemen üstünde, aynı sağ kenara hizalı: gözün zaten baktığı
köşe ve orası YouTube'un kendi rozetleri dışında boş. */
.ob-badge {
position: absolute;
right: 4px;
bottom: 30px;
z-index: 20;
min-width: 34px;
padding: 3px 7px;
border-radius: 5px;
font: 700 14px/1.3 "Roboto", "Segoe UI", sans-serif;
text-align: center;
color: #fff;
background: rgba(0, 0, 0, 0.78);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
pointer-events: auto;
cursor: default;
white-space: nowrap;
}
/* Shorts kapağının altı izlenme metniyle dolu, orada rozet üste gider. */
.ob-badge.ob-short {
top: 6px;
bottom: auto;
}
.ob-badge.ob-loading {
min-width: 0;
width: 9px;
height: 9px;
padding: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
animation: ob-pulse 1.1s ease-in-out infinite;
}
@keyframes ob-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.9; }
}
.ob-badge.ob-none { opacity: 0.45; }
.ob-badge.ob-young::after { content: " ·"; opacity: 0.7; }
.ob-t0 { background: rgba(60, 60, 60, 0.82); color: #d0d0d0; }
.ob-t1 { background: rgba(29, 92, 168, 0.9); }
.ob-t2 { background: rgba(21, 128, 61, 0.92); }
.ob-t3 { background: rgba(180, 83, 9, 0.94); }
.ob-t4 { background: rgba(159, 18, 57, 0.95); }
/* --- Izleme sayfasi paneli --- */
.ob-panel {
margin: 12px 0;
padding: 12px 14px;
border: 1px solid var(--yt-spec-10-percent-layer, rgba(128, 128, 128, 0.25));
border-radius: 10px;
color: var(--yt-spec-text-primary, inherit);
font-family: "Roboto", "Segoe UI", sans-serif;
font-size: 13px;
}
.ob-panel-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.ob-chip {
display: inline-block;
padding: 3px 8px;
border-radius: 6px;
color: #fff;
font-weight: 700;
font-size: 15px;
}
.ob-head-text {
color: var(--yt-spec-text-secondary, #909090);
}
.ob-panel-body {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 4px 18px;
}
.ob-row {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 2px 0;
}
.ob-row-label { color: var(--yt-spec-text-secondary, #909090); }
.ob-row-value { font-weight: 500; }
.ob-section-title {
margin: 12px 0 4px;
color: var(--yt-spec-text-secondary, #909090);
font-size: 12px;
}
.ob-chart {
display: block;
width: 100%;
height: 52px;
}
.ob-bar { fill: var(--yt-spec-text-secondary, #909090); opacity: 0.45; }
.ob-bar-self { fill: #f59e0b; opacity: 1; }
.ob-median { stroke: #ef4444; stroke-width: 0.6; stroke-dasharray: 2 2; }
.ob-note {
margin-top: 10px;
color: var(--yt-spec-text-secondary, #909090);
font-size: 11px;
line-height: 1.5;
}