mirror of
https://github.com/yusufipk/YouTubeOutlierBadge.git
synced 2026-09-11 10:56:14 +00:00
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:
@@ -0,0 +1,3 @@
|
||||
web-ext-artifacts/
|
||||
node_modules/
|
||||
*.xpi
|
||||
@@ -0,0 +1,81 @@
|
||||
# Outlier Badge
|
||||
|
||||
[Türkçe](README.tr.md)
|
||||
|
||||
A Firefox extension that puts an outlier score on the corner of every YouTube
|
||||
thumbnail. Score = the video's views divided by the **median** views of its
|
||||
channel's last N videos. The median is a deliberate choice: a single viral video
|
||||
would drag a mean upward and make everything else on that channel look like it
|
||||
never outperformed. Shorts are scored against the channel's Shorts, regular
|
||||
videos against regular videos, and live streams enter no pool at all. The watch
|
||||
page also gets a panel with the score's details and the channel's recent view
|
||||
distribution.
|
||||
|
||||
No server, no API key, no account: the extension calls YouTube's own internal
|
||||
(InnerTube) endpoint from inside youtube.com, without session credentials. One
|
||||
request per channel, cached in `storage.local` for 24 hours by default, and cards
|
||||
are only processed once they scroll into view.
|
||||
|
||||
## Trying it out
|
||||
|
||||
`about:debugging#/runtime/this-firefox` → **Load Temporary Add-on** → the
|
||||
`manifest.json` in this directory. It disappears when Firefox closes.
|
||||
|
||||
## Installing it for good
|
||||
|
||||
Release Firefox refuses to permanently install an unsigned add-on. Getting it
|
||||
signed is free and shows the extension to nobody:
|
||||
|
||||
1. Create an account at addons.mozilla.org/developers and grab a JWT issuer and
|
||||
secret from **Manage API Keys**.
|
||||
2. Sign the package (inside a container so npm never touches this directory):
|
||||
|
||||
```
|
||||
podman run --rm -v "$(pwd)":/w:z -w /w docker.io/node:alpine \
|
||||
npx --yes web-ext sign --channel=unlisted \
|
||||
--api-key=JWT_ISSUER --api-secret=JWT_SECRET
|
||||
```
|
||||
|
||||
3. Install the resulting `web-ext-artifacts/*.xpi` through `about:addons` → gear
|
||||
icon → **Install Add-on From File**.
|
||||
|
||||
The signature is tied to the add-on id in `manifest.json`, so leave that id
|
||||
alone. Every new signature needs a bumped `version`, and unlisted add-ons do not
|
||||
auto-update: when you change the code you sign and install it again. If you would
|
||||
rather skip signing altogether, Firefox Developer Edition and Nightly honour
|
||||
`xpinstall.signatures.required = false` in `about:config`, but release Firefox
|
||||
ignores that setting.
|
||||
|
||||
## Using it
|
||||
|
||||
The toolbar icon opens the settings: turn badges off, skip Shorts, pick the
|
||||
baseline size (last 15/30/60/90 videos), hide scores below a threshold, set the
|
||||
cache lifetime, choose the interface language, and clear the cache. The same
|
||||
popup reports how many cards were scanned and scored in that tab, which is where
|
||||
you look first when a badge fails to appear. The interface is available in
|
||||
English and Turkish, following the browser's language by default.
|
||||
|
||||
To exercise the parsing and scoring logic without opening a browser, run
|
||||
`node test/check.js` (it makes one real channel call too).
|
||||
|
||||
## Known limits
|
||||
|
||||
- YouTube hands out rounded view counts ("1.2M views"), so the score is an
|
||||
approximate ratio rather than an exact one.
|
||||
- Videos under 7 days old have not gathered their views yet and score lower than
|
||||
they deserve. The dot next to the badge marks them.
|
||||
- The baseline is the channel's last N videos, not its whole history. On a
|
||||
growing channel an old video scores higher than it should.
|
||||
- Members-only videos never expose their view counts, so they neither join a
|
||||
baseline nor get a score.
|
||||
- With fewer than 3 videos left in the pool no score is computed and no badge
|
||||
appears.
|
||||
- InnerTube is an unofficial interface. If YouTube renames its fields the badges
|
||||
disappear; the page itself keeps working.
|
||||
|
||||
There is **no** views-over-time chart, because YouTube publishes no historical
|
||||
view data for any video. vidIQ, Social Blade and everyone else build that curve
|
||||
from snapshots they collect themselves; in a single-user extension it would take
|
||||
months to appear and only for videos you personally opened. The watch page panel
|
||||
shows the two things that work without waiting instead: where the video sits in
|
||||
its channel's distribution, and its average views per day.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Outlier Badge
|
||||
|
||||
[English](README.md)
|
||||
|
||||
YouTube'da her video kartının köşesine outlier skorunu basan Firefox eklentisi.
|
||||
Skor = videonun izlenmesi / kanalın son N videosunun izlenme **medyanı**. Medyan
|
||||
bilerek seçildi: tek bir viral video ortalamayı yukarı çekip aynı kanalın diğer
|
||||
videolarını "outlier değil" gösterirdi. Shorts kendi havuzunda, normal videolar
|
||||
kendi havuzunda puanlanır, canlı yayınlar hiçbir havuza girmez. Video sayfasında
|
||||
ayrıca skorun ayrıntısı ve kanalın son videolarının izlenme dağılımı görünür.
|
||||
|
||||
Sunucu, API anahtarı, hesap yok: eklenti youtube.com içinden YouTube'un kendi
|
||||
dahili (InnerTube) ucuna oturumsuz istek atar. Kanal başına bir istek, sonucu
|
||||
`storage.local` içinde varsayılan 24 saat tutulur, kartlar ancak ekrana girince
|
||||
işlenir.
|
||||
|
||||
## Denemek için
|
||||
|
||||
`about:debugging#/runtime/this-firefox` → **Geçici Eklenti Yükle** → bu dizindeki
|
||||
`manifest.json`. Firefox kapanınca kaybolur.
|
||||
|
||||
## Kalıcı kurulum
|
||||
|
||||
Normal Firefox imzasız eklentiyi kalıcı kurmaz. İmzalatmak ücretsiz ve eklentiyi
|
||||
kimseye göstermez:
|
||||
|
||||
1. addons.mozilla.org/developers'da hesap aç, **Manage API Keys** sayfasından JWT
|
||||
issuer ve secret al.
|
||||
2. Paketi imzalat (npm bu dizine bir şey kurmasın diye kapsayıcı içinde):
|
||||
|
||||
```
|
||||
podman run --rm -v "$(pwd)":/w:z -w /w docker.io/node:alpine \
|
||||
npx --yes web-ext sign --channel=unlisted \
|
||||
--api-key=JWT_ISSUER --api-secret=JWT_SECRET
|
||||
```
|
||||
|
||||
3. Çıkan `web-ext-artifacts/*.xpi` dosyasını `about:addons` → dişli simgesi →
|
||||
**Dosyadan eklenti yükle** ile kur.
|
||||
|
||||
İmza `manifest.json` içindeki eklenti kimliğine bağlı, o yüzden kimliği
|
||||
değiştirme. Her yeni imza için `version` alanını artırman gerekir ve listelenmemiş
|
||||
eklentiler otomatik güncellenmez: kodu değiştirdikçe imzalayıp yeniden kurarsın.
|
||||
İmzayla uğraşmak istemezsen Firefox Developer Edition veya Nightly kurup
|
||||
`about:config` içinde `xpinstall.signatures.required` değerini `false` yapmak da
|
||||
çalışır, ama bu ayar normal Firefox'ta yok sayılır.
|
||||
|
||||
## Kullanım
|
||||
|
||||
Araç çubuğundaki simge ayarları açar: rozetleri kapat, Shorts'u puanlama, baseline
|
||||
boyutu (son 15/30/60/90 video), belirli bir skorun altını gizle, önbellek ömrü,
|
||||
arayüz dili ve önbellek temizleme. Aynı pencerede o sekmede kaç kartın taranıp
|
||||
kaçının skorlandığı yazar, rozet çıkmıyorsa nedeni oradan görünür. Arayüz Türkçe
|
||||
ve İngilizce, varsayılan olarak tarayıcının diline uyar.
|
||||
|
||||
Ayrıştırma ve skor mantığını tarayıcı açmadan denemek için `node test/check.js`
|
||||
(gerçek bir kanal çağrısı da atar).
|
||||
|
||||
## Bilinen sınırlar
|
||||
|
||||
- Kanal izlenmeleri YouTube tarafından yuvarlanmış gelir ("1.2M views"), skor
|
||||
yaklaşık bir orandır, kesin değildir.
|
||||
- 7 günden yeni videolar henüz izlenme toplamamıştır, skorları olduğundan düşük
|
||||
çıkar. Rozetin yanındaki nokta bunu işaretler.
|
||||
- Baseline kanalın son N videosudur, tüm geçmişi değil. Büyüyen bir kanalda eski
|
||||
bir video hak ettiğinden yüksek skor alır.
|
||||
- Üyelere özel videoların izlenmesi dışarıya hiç verilmiyor: ne baseline'a
|
||||
girerler ne de puanlanabilirler.
|
||||
- Havuzda 3'ten az video kalırsa skor hesaplanmaz, rozet çıkmaz.
|
||||
- InnerTube resmi olmayan bir arayüz. YouTube alan adlarını değiştirirse rozet
|
||||
kaybolur, sayfa bozulmaz.
|
||||
|
||||
Zaman içinde izlenme grafiği **yok**, çünkü YouTube hiçbir video için geçmiş
|
||||
izlenme verisi vermiyor. vidIQ ve Social Blade dahil herkes o eğriyi kendi
|
||||
topladığı anlık ölçümlerden üretiyor; tek kullanıcılık bir eklentide ancak aylar
|
||||
sonra ve yalnızca senin açtığın videolar için oluşurdu. Onun yerine video
|
||||
sayfasındaki panelde beklemeden işe yarayan iki şey var: videonun kanal
|
||||
dağılımındaki yeri ve günlük ortalama izlenmesi.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Outlier Badge",
|
||||
"description": "Extension name"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Puts an outlier score on the corner of every YouTube thumbnail: the video's views over its own channel's median.",
|
||||
"description": "Extension description"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Outlier Badge",
|
||||
"description": "Eklenti adı"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Her YouTube kapağının köşesine outlier skorunu basar: videonun izlenmesi, kendi kanalının medyanının kaç katı.",
|
||||
"description": "Eklenti açıklaması"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#1f1f1f"/>
|
||||
<rect x="12" y="40" width="8" height="12" rx="2" fill="#8a8a8a"/>
|
||||
<rect x="24" y="34" width="8" height="18" rx="2" fill="#8a8a8a"/>
|
||||
<rect x="36" y="38" width="8" height="14" rx="2" fill="#8a8a8a"/>
|
||||
<rect x="48" y="12" width="8" height="40" rx="2" fill="#f59e0b"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "__MSG_extName__",
|
||||
"version": "0.1.0",
|
||||
"description": "__MSG_extDescription__",
|
||||
"default_locale": "en",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "outlier-badge@yusufipk",
|
||||
"strict_min_version": "115.0"
|
||||
}
|
||||
},
|
||||
"permissions": ["storage"],
|
||||
"host_permissions": ["*://www.youtube.com/*"],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["*://www.youtube.com/*"],
|
||||
"js": [
|
||||
"src/i18n.js",
|
||||
"src/parse.js",
|
||||
"src/innertube.js",
|
||||
"src/store.js",
|
||||
"src/score.js",
|
||||
"src/badge.js",
|
||||
"src/panel.js",
|
||||
"src/content.js"
|
||||
],
|
||||
"css": ["src/style.css"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"icons": { "64": "icons/icon.svg" },
|
||||
"action": {
|
||||
"default_title": "Outlier Badge",
|
||||
"default_popup": "popup/popup.html",
|
||||
"default_icon": { "64": "icons/icon.svg" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
body {
|
||||
width: 280px;
|
||||
margin: 0;
|
||||
padding: 12px 14px 14px;
|
||||
font: 13px/1.45 "Segoe UI", Roboto, sans-serif;
|
||||
color: #eee;
|
||||
background: #202124;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
select {
|
||||
background: #303134;
|
||||
color: #eee;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #303134;
|
||||
color: #eee;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover { background: #3c4043; }
|
||||
|
||||
.wide { width: 100%; margin-top: 8px; }
|
||||
|
||||
.warn {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background: #4a2f12;
|
||||
color: #ffd8a8;
|
||||
}
|
||||
|
||||
.warn button { margin-top: 6px; }
|
||||
|
||||
.stats {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #3c4043;
|
||||
color: #9aa0a6;
|
||||
font-size: 12px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 10px 0 0;
|
||||
color: #9aa0a6;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Outlier Badge</h1>
|
||||
|
||||
<div id="permission" class="warn" hidden>
|
||||
<span data-i18n="popupPermission"></span>
|
||||
<button id="grant" type="button" data-i18n="popupGrant"></button>
|
||||
</div>
|
||||
|
||||
<label class="row">
|
||||
<span data-i18n="popupEnabled"></span>
|
||||
<input type="checkbox" id="enabled">
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span data-i18n="popupScoreShorts"></span>
|
||||
<input type="checkbox" id="scoreShorts">
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span data-i18n="popupShowPanel"></span>
|
||||
<input type="checkbox" id="showPanel">
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span data-i18n="popupBaselineSize"></span>
|
||||
<select id="baselineSize"></select>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span data-i18n="popupMinScore"></span>
|
||||
<select id="minScore"></select>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span data-i18n="popupTtl"></span>
|
||||
<select id="ttlHours"></select>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span data-i18n="popupLanguage"></span>
|
||||
<select id="language"></select>
|
||||
</label>
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<button id="clear" type="button" class="wide" data-i18n="popupClear"></button>
|
||||
|
||||
<p class="note" data-i18n="popupNote"></p>
|
||||
|
||||
<script src="../src/i18n.js"></script>
|
||||
<script src="../src/store.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/* Ayar penceresi. Kaydetmek için düğme yok: her değişiklik anında yazılır,
|
||||
* açık YouTube sekmeleri storage.onChanged ile kendini yeniler. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var api = (typeof browser !== "undefined" ? browser : chrome);
|
||||
var T = OBI18n;
|
||||
var ORIGINS = { origins: ["*://www.youtube.com/*"] };
|
||||
|
||||
var FIELDS = [
|
||||
["enabled", "checkbox"],
|
||||
["scoreShorts", "checkbox"],
|
||||
["showPanel", "checkbox"],
|
||||
["baselineSize", "number"],
|
||||
["minScore", "number"],
|
||||
["ttlHours", "number"],
|
||||
["language", "text"]
|
||||
];
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function option(select, value, text) {
|
||||
var opt = document.createElement("option");
|
||||
opt.value = String(value);
|
||||
opt.textContent = text;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
/* Seçenek metinleri dile göre değiştiği için HTML'de değil burada üretilir. */
|
||||
function buildOptions() {
|
||||
var sizes = $("baselineSize");
|
||||
sizes.textContent = "";
|
||||
[15, 30, 60, 90].forEach(function (n) { option(sizes, n, T.t("optLastN", n)); });
|
||||
|
||||
var scores = $("minScore");
|
||||
scores.textContent = "";
|
||||
option(scores, 0, T.t("optShowAll"));
|
||||
[1, 1.5, 2, 3].forEach(function (n) { option(scores, n, T.num(n, n % 1 ? 1 : 0) + "x"); });
|
||||
|
||||
var ttl = $("ttlHours");
|
||||
ttl.textContent = "";
|
||||
option(ttl, 6, T.t("optHours", 6));
|
||||
option(ttl, 24, T.t("optHours", 24));
|
||||
option(ttl, 72, T.t("optDays", 3));
|
||||
option(ttl, 168, T.t("optWeek"));
|
||||
|
||||
var lang = $("language");
|
||||
lang.textContent = "";
|
||||
option(lang, "auto", T.t("optAuto"));
|
||||
option(lang, "tr", "Türkçe");
|
||||
option(lang, "en", "English");
|
||||
}
|
||||
|
||||
function applyTexts() {
|
||||
var nodes = document.querySelectorAll("[data-i18n]");
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
nodes[i].textContent = T.t(nodes[i].dataset.i18n);
|
||||
}
|
||||
}
|
||||
|
||||
function fillValues(s) {
|
||||
FIELDS.forEach(function (f) {
|
||||
var node = $(f[0]);
|
||||
if (f[1] === "checkbox") node.checked = !!s[f[0]];
|
||||
else node.value = String(s[f[0]]);
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
return OBStore.settings().then(function (s) {
|
||||
T.init(s.language);
|
||||
applyTexts();
|
||||
buildOptions();
|
||||
fillValues(s);
|
||||
return OBStore.stats();
|
||||
}).then(function (st) {
|
||||
$("stats").textContent = T.t("popupStatsChannels", st.channels);
|
||||
return askTabStats();
|
||||
});
|
||||
}
|
||||
|
||||
/* Açık YouTube sekmesindeki sayaçlar: rozet çıkmıyorsa nedeni burada görünür. */
|
||||
function askTabStats() {
|
||||
return api.tabs.query({ active: true, currentWindow: true }).then(function (tabs) {
|
||||
if (!tabs.length) return;
|
||||
return api.tabs.sendMessage(tabs[0].id, { type: "ob-stats" }).then(function (r) {
|
||||
if (!r) return;
|
||||
var c = r.counters;
|
||||
var line = T.t("popupStatsTab", c.seen, c.scored);
|
||||
if (c.skipped) line += T.t("popupStatsSkipped", c.skipped);
|
||||
if (c.failed) line += T.t("popupStatsFailed", c.failed);
|
||||
$("stats").textContent += "\n" + line + ".";
|
||||
if (r.lastError) $("stats").textContent += "\n" + T.t("popupLastError", r.lastError);
|
||||
});
|
||||
}).catch(function () { /* sekmede içerik betiği yok, önemsiz */ });
|
||||
}
|
||||
|
||||
function bind() {
|
||||
FIELDS.forEach(function (f) {
|
||||
$(f[0]).addEventListener("change", function () {
|
||||
var node = $(f[0]);
|
||||
var patch = {};
|
||||
patch[f[0]] = f[1] === "checkbox" ? node.checked
|
||||
: f[1] === "number" ? parseFloat(node.value) : node.value;
|
||||
OBStore.saveSettings(patch).then(function () {
|
||||
if (f[0] === "language") refresh();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$("clear").addEventListener("click", function () {
|
||||
OBStore.clearBaselines().then(function () {
|
||||
$("stats").textContent = T.t("popupCleared");
|
||||
});
|
||||
});
|
||||
|
||||
$("grant").addEventListener("click", function () {
|
||||
api.permissions.request(ORIGINS).then(function (granted) {
|
||||
if (granted) $("permission").hidden = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
api.permissions.contains(ORIGINS).then(function (has) {
|
||||
$("permission").hidden = !!has;
|
||||
}).catch(function () {});
|
||||
|
||||
bind();
|
||||
refresh();
|
||||
})();
|
||||
+105
@@ -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
@@ -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
@@ -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 };
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/* Eklenti mantığının Node altında doğrulanması: ayrıştırma ve skor birim
|
||||
* testleriyle, InnerTube tarafı gerçek bir kanal çağrısıyla. */
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const path = require("path");
|
||||
|
||||
const dir = path.join(__dirname, "..", "src");
|
||||
const sandbox = {
|
||||
console,
|
||||
fetch,
|
||||
navigator: { language: "tr-TR" },
|
||||
document: { querySelectorAll: () => [], documentElement: { lang: "en" } },
|
||||
setTimeout,
|
||||
Promise,
|
||||
Date,
|
||||
Math,
|
||||
JSON,
|
||||
Object,
|
||||
Array,
|
||||
parseInt,
|
||||
parseFloat,
|
||||
isNaN,
|
||||
String
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
for (const f of ["i18n.js", "parse.js", "innertube.js", "score.js"]) {
|
||||
vm.runInContext(fs.readFileSync(path.join(dir, f), "utf8"), sandbox, { filename: f });
|
||||
}
|
||||
const { OBParse, OBTube, OBI18n } = sandbox;
|
||||
OBI18n.init("tr");
|
||||
|
||||
let fails = 0;
|
||||
function eq(actual, expected, label) {
|
||||
const ok = typeof actual === "number" && typeof expected === "number"
|
||||
? Math.abs(actual - expected) < 1e-9
|
||||
: actual === expected;
|
||||
if (!ok) fails++;
|
||||
console.log((ok ? "ok " : "FAIL ") + label + " -> " + actual + (ok ? "" : " (beklenen " + expected + ")"));
|
||||
}
|
||||
|
||||
console.log("--- sayı ayrıştırma (InnerTube, hep İngilizce) ---");
|
||||
eq(OBParse.parseCountEn("1.2M views"), 1200000, "1.2M views");
|
||||
eq(OBParse.parseCountEn("341,331 views"), 341331, "341,331 views");
|
||||
eq(OBParse.parseCountEn("24K views"), 24000, "24K views");
|
||||
eq(OBParse.parseCountEn("1.4B views"), 1400000000, "1.4B views");
|
||||
eq(OBParse.parseCountEn("No views"), null, "No views");
|
||||
eq(OBParse.parseCountEn("2 months ago"), 2, "yaş metni sayı vermemeli (2 döner, filtre dışarıda)");
|
||||
|
||||
console.log("--- sayı ayrıştırma (sayfa DOM'u, arayüz dili) ---");
|
||||
eq(OBParse.parseCountUI("24 B görüntüleme", "tr"), 24000, "tr: 24 B = 24 bin");
|
||||
eq(OBParse.parseCountUI("1,3 Mn görüntüleme", "tr"), 1300000, "tr: 1,3 Mn");
|
||||
eq(OBParse.parseCountUI("2,1 Mr görüntüleme", "tr"), 2100000000, "tr: 2,1 Mr");
|
||||
eq(OBParse.parseCountUI("1.234.567 görüntüleme", "tr"), 1234567, "tr: tam sayı");
|
||||
eq(OBParse.parseCountUI("1.2M views", "en"), 1200000, "en: 1.2M");
|
||||
eq(OBParse.parseCountUI("24B views", "en"), 24000000000, "en: B = milyar");
|
||||
|
||||
console.log("--- yaş ---");
|
||||
eq(Math.round(OBParse.parseAgeDays("3 months ago")), 91, "3 months ago");
|
||||
eq(OBParse.parseAgeDays("2 days ago"), 2, "2 days ago");
|
||||
eq(OBParse.parseAgeDays("3 ay önce"), 91.32, "3 ay önce");
|
||||
eq(OBParse.parseAgeDays("5 saat önce"), 5 / 24, "5 saat önce");
|
||||
eq(OBParse.parseAgeDays("Streamed 2 weeks ago"), 14, "Streamed 2 weeks ago");
|
||||
|
||||
console.log("--- medyan ve gösterim ---");
|
||||
eq(OBParse.median([3, 1, 2]), 2, "medyan tek sayıda");
|
||||
eq(OBParse.median([4, 1, 2, 3]), 2.5, "medyan çift sayıda");
|
||||
eq(OBParse.humanCount(1234567), "1,2 Mn", "humanCount milyon (tr)");
|
||||
eq(OBParse.humanCount(24000), "24 B", "humanCount bin (tr)");
|
||||
eq(OBParse.humanCount(965400), "965 B", "humanCount yüz binler (tr)");
|
||||
|
||||
console.log("--- dil ---");
|
||||
OBI18n.init("en");
|
||||
eq(OBParse.humanCount(1234567), "1.2M", "humanCount milyon (en)");
|
||||
eq(OBParse.humanCount(24000), "24K", "humanCount bin (en)");
|
||||
eq(OBI18n.num(2.4, 1), "2.4", "en ondalık nokta");
|
||||
eq(OBI18n.t("badgeViews", "1.2M"), "Views: 1.2M", "en metin");
|
||||
OBI18n.init("tr");
|
||||
eq(OBI18n.num(2.4, 1), "2,4", "tr ondalık virgül");
|
||||
eq(OBI18n.t("badgeViews", "1,2 Mn"), "İzlenme: 1,2 Mn", "tr metin");
|
||||
eq(OBI18n.t("badgeMedian", "45 B", 27, "video"),
|
||||
"Kanal medyanı: 45 B (son 27 video)", "tr yer tutuculu metin");
|
||||
|
||||
console.log("--- skor ---");
|
||||
const { OBScore } = sandbox;
|
||||
const pool = { video: [], short: null };
|
||||
[100, 200, 300, 400, 500].forEach((v, i) => pool.video.push({ id: "v" + i, views: v * 1000, age: 10 }));
|
||||
pool.video.push({ id: "hit", views: 1500000, age: 3 });
|
||||
const r = OBScore.score("hit", 1500000, false, pool);
|
||||
eq(r.median, 300000, "medyan kendisi hariç (100..500 bin)");
|
||||
eq(r.score, 5, "skor 1.5M / 300K");
|
||||
eq(r.sample, 5, "örnek sayısı");
|
||||
eq(r.percentile, 1, "hepsinden yüksek");
|
||||
const low = OBScore.score("v0", 100000, false, pool);
|
||||
/* v0 çıkınca kalan [200,300,400,500,1500] bin -> medyan 400 bin */
|
||||
eq(low.median, 400000, "kendisi çıkınca medyan kayar");
|
||||
const tiny = OBScore.score("x", 5000, false, { video: [{ id: "a", views: 1 }, { id: "b", views: 2 }] });
|
||||
eq(tiny.score, null, "3'ten az örnek: skor yok");
|
||||
|
||||
console.log("\n--- gerçek InnerTube çağrısı ---");
|
||||
const CHANNEL = process.argv[2] || "UCXuqSBlHAE6Xw-yeJA0Tunw"; /* Linus Tech Tips */
|
||||
OBTube.channelTab(CHANNEL, "videos", 30).then((items) => {
|
||||
console.log("videos sekmesi: " + items.length + " video");
|
||||
const withViews = items.filter((v) => v.views);
|
||||
console.log("izlenmesi çözülen: " + withViews.length);
|
||||
console.log("canlı yayın işaretlenen: " + items.filter((v) => v.wasLive).length);
|
||||
console.log("üyelere özel: " + items.filter((v) => v.membersOnly).length);
|
||||
for (const v of items.slice(0, 3)) {
|
||||
console.log(" " + v.videoId + " | " + OBParse.humanCount(v.views) + " | " +
|
||||
(v.ageDays == null ? "?" : v.ageDays.toFixed(1) + " gün") + " | " + v.title.slice(0, 50));
|
||||
}
|
||||
const views = withViews.map((v) => v.views);
|
||||
console.log("medyan: " + OBParse.humanCount(OBParse.median(views)));
|
||||
if (withViews.length < 10) { fails++; console.log("FAIL izlenmesi çözülen video sayısı çok düşük"); }
|
||||
return OBTube.channelTab(CHANNEL, "shorts", 30);
|
||||
}).then((shorts) => {
|
||||
console.log("shorts sekmesi: " + shorts.length + " video, izlenmesi çözülen: " +
|
||||
shorts.filter((v) => v.views).length);
|
||||
return OBTube.resolveChannel("@LinusTechTips");
|
||||
}).then((id) => {
|
||||
eq(id, CHANNEL, "handle -> kanal kimliği");
|
||||
return OBTube.videoDetails("dQw4w9WgXcQ");
|
||||
}).then((d) => {
|
||||
console.log("player ucu: kanal " + d.channelId + ", izlenme " + OBParse.humanCount(d.views));
|
||||
if (!d.channelId) { fails++; console.log("FAIL player ucu kanal kimliği vermedi"); }
|
||||
console.log(fails ? "\n" + fails + " test başarısız" : "\nhepsi geçti");
|
||||
process.exit(fails ? 1 : 0);
|
||||
}).catch((e) => {
|
||||
console.log("HATA: " + e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user