// ==UserScript==
// @name F95Zone Thread Watcher & Metrics
// @icon https://external-content.duckduckgo.com/iu/?u=https://f95zone.to/data/avatars/l/1963/1963870.jpg?1744969685
// @namespace https://f95zone.to/members/x-death.1963870/
// @homepage https://greasyfork.org/en/scripts/546563
// @homepageURL https://greasyfork.org/en/scripts/546563
// @author X Death on F95zone
// @match https://f95zone.to/
// @grant GM.setValue
// @grant GM.getValues
// @run-at document-idle
// @version 2.0.0
// @description Alerts on new threads & user notifications, tracks upload frequency with data metrics, manages ignored prefixes, and adds colorful UI enhancements.
// @license GPL-3.0-or-later
// ==/UserScript==
(async () => {
/** ----------------------------
* GLOBAL VARIABLE
* ---------------------------- */
const manualRefresh = false;
const intervalManualRefresh = 60000;
const debug = false;
let defaultData = {
previousData: [],
newGameLog: [],
uploadTimestamps: [],
notification: true,
configVisibility: true,
userAlert: false,
knownPrefixes: [],
ignoredPrefix: [],
totalEntries: 100,
manualCheck: false,
manualCheckInterval: 1,
}
let currentData = {};
let newDataExist = false;
let newGameNotif = false;
let isNotifAllowed = false;
let firstNotif = true;
let modalInjected = false;
let curTotalAlert = 0;
let isThreadSafe = false;
let isAlertSafe = false;
let errorMsg = "";
let manualCheckTimer = null;
let threadLostFocus = true;
let isObserverAlertInit = false;
let isObserverThreadInit = false;
/** ----------------------------
* Storage
* ---------------------------- */
//load datas
async function loadData() {
let parsed = {};
try {
parsed = (await GM.getValues(Object.keys(defaultData))) ?? {};
} catch (e) {
parsed = {};
}
return {
previousData: Array.isArray(parsed.previousData) ? parsed.previousData : [],
newGameLog: Array.isArray(parsed.newGameLog) ?
parsed.newGameLog.map(item => {
if (typeof item === "string") return {
title: item,
link: ""
};
return {
title: item.title || "",
link: item.link || ""
};
}) :
[],
uploadTimestamps: Array.isArray(parsed.uploadTimestamps) ? parsed.uploadTimestamps : [],
notification: !!parsed.notification,
configVisibility: parsed.configVisibility === undefined ? true : !!parsed.configVisibility,
userAlert: !!parsed.userAlert,
knownPrefixes: Array.isArray(parsed.knownPrefixes) ? parsed.knownPrefixes : [],
ignoredPrefix: Array.isArray(parsed.ignoredPrefix) ? parsed.ignoredPrefix : [],
totalEntries: parsed.totalEntries ?? 100,
manualCheck: !!parsed.manualCheck,
manualCheckInterval: parsed.manualCheckInterval ?? 1,
};
}
//save data
async function saveDatas(data, restart = false) {
const ops = [];
for (const [key, value] of Object.entries(data)) {
ops.push(GM.setValue(key, value));
}
await Promise.all(ops);
if (restart) await reload();
}
async function reload() {
if ((currentData.notification || currentData.userAlert)) {
isNotifAllowed = await askForPermission();
}
return true;
}
async function backupData() {
try {
const currentData = await loadData();
const blob = new Blob([JSON.stringify(currentData, null, 2)], {
type: "application/json"
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `game-data-backup-${new Date().toISOString().split("T")[0]}.json`;
a.click();
URL.revokeObjectURL(url);
alert("Backup created successfully!");
} catch (e) {
console.error("Backup failed", e);
alert("Failed to create backup");
}
}
function restoreData(data) {
// Simple validation: check expected keys
const requiredKeys = [
"previousData",
"newGameLog",
"uploadTimestamps",
"notification",
"configVisibility",
"userAlert",
"knownPrefixes",
"ignoredPrefix",
"totalEntries",
"manualCheck",
"manualCheckInterval"
];
const missingKeys = requiredKeys.filter(key => !(key in data));
if (missingKeys.length) {
alert("JSON is missing keys: " + missingKeys.join(", "));
return;
}
// Optional: coerce newGameLog entries to { title, link } if needed
data.newGameLog = data.newGameLog.map(item => {
if (typeof item === "string") return {
title: item,
link: ""
};
return {
title: item.title || "",
link: item.link || ""
};
});
// Replace currentData and save
currentData = data;
saveDatas(currentData);
renderUI();
alert("Data restored successfully!");
}
/** ----------------------------
* UI
* ---------------------------- */
function injectButton() {
const button = document.createElement("button");
button.textContent = "⛯";
button.id = "tag-config-button";
button.addEventListener("click", () => openModal());
document.body.appendChild(button);
}
function injectModal() {
modalInjected = true;
const modal = document.createElement("div");
modal.id = "tag-config-modal";
Object.assign(modal.style, {
display: "none",
position: "fixed",
zIndex: 9999,
top: 0,
left: 0,
width: "100%",
height: "100%",
backgroundColor: "rgba(0,0,0,0.5)",
});
modal.innerHTML = `
<div class="modal-content" style="background:#191b1e; max-width:400px; margin:100px auto; border-radius:10px;">
<h2 style="text-align: center;">MENU</h2>
<div class="modal-settings-spacing">
<div id="modal-warning-msg"></div>
<div class="modal-settings-spacing">
<details class="config-list-details">
<summary>New game Lists</summary>
<div style="padding:10px; color:#ccc; font-size:14px;">
<div id="metrics-info" style="line-height:1.6;">
<div id="new-game-list-container" style="max-height: 300px; overflow-y: auto; padding:5px; border:1px solid #555; background:#2c3032; border-radius:4px;"></div>
<div id="new-game-info" style="color: #c15858;text-align: center;"></div>
<div style="padding:10px;display:flex;justify-content:center;">
<button class="modal-btn" id="resetNewGameLog" title="Reset previous data" style="margin-right:5px;">Reset new game log</button>
<button class="modal-btn" id="check-manually" title="Check manually">Check manually</button>
</div>
</div>
</div>
</details>
</div>
<!-- Ignore prefix --!>
<hr class="thick-line" />
<div class="modal-settings-spacing">
<details class="config-list-details">
<summary>Ignore prefix</summary>
<div style="padding:10px; color:#ccc; font-size:14px;">
<div id="metrics-info" style="line-height:1.6;">
<div id="search-container" style="position: relative; display: inline-block; min-height: 250px; width:100%;">
<input type="text" id="prefix-search" placeholder="Search prefixes..." autocomplete="off">
<ul id="search-results">
</ul>
<div id="ignored-prefix-list"></div>
</div>
</div>
</div>
</details>
</div>
<!-- Metrics --!>
<hr class="thick-line" />
<div class="modal-settings-spacing">
<details class="config-list-details">
<summary>Upload Statistics</summary>
<div style="padding:10px; color:#ccc; font-size:14px;">
<div id="metrics-info" style="line-height:1.6;">
<div>Previous Data Count: <span id="metric-prev">0</span>
</div>
<div>New Game Log Count: <span id="metric-newgame">0</span>
</div>
<div>Upload Timestamps (last 30 days): <span id="metric-upload">0</span>
</div>
<pre id="upload-frequency"></pre>
<div class="config-row">
<label for="restore-data">Restore Data</label>
<input type="file" id="restore-data" accept=".json">
</div>
<div class="modal-btn-section" style="margin-top: 10px;">
<button class="modal-btn modal-btn-reset" id="backup-data" title="Backup data">Backup data</button>
</div>
</div>
</div>
</details>
</div>
<hr class="thick-line" />
<!-- General -->
<div class="modal-settings-spacing">
<details class="config-list-details">
<summary>Settigns</summary>
<div id="config-container">
<div id="alert-notif" style="margin-top: 10px;" class="config-row">
<label for="user-alert" style="width: 160px;">User Alert</label>
<input type="checkbox" id="user-alert">
</div>
<div style="margin-top: 10px;" class="config-row">
<label for="notification" style="width: 160px;">Notification</label>
<input type="checkbox" id="notification">
</div>
<div style="margin-top: 10px;" class="config-row">
<label for="config-visibility" style="width: 160px;">Config Visibility</label>
<input type="checkbox" id="config-visibility">
</div>
<div style="margin-top: 10px;" class="config-row">
<label for="manual-check" style="width: 160px;">manual check</label>
<input type="checkbox" id="manual-check">
</div>
<div style="margin-top: 10px;" class="config-row">
<label for="manual-check-interval" style="width: 160px;">manual check interval(m)</label>
<input type="number" id="manual-check-interval" min="1" value="1" step="1" required>
</div>
<div style="margin-top: 10px;" class="config-row">
<label for="total-entries" style="width: 160px;">Total Entries</label>
<select id="total-entries">
<option value="20">20</option>
<option value="40">40</option>
<option value="60">60</option>
<option value="80">80</option>
<option value="100">100</option>
</select>
</div>
<div style="padding:10px;display:flex;justify-content:center;">
<button id="save-config" class="modal-btn" style="margin-right:5px;">⭳ Save</button>
</div>
</div>
</details>
</div>
<hr class="thick-line" />
<div class="modal-settings-spacing">
<details class="config-list-details">
<summary>Resets</summary>
<div class="modal-btn-section">
<button class="modal-btn modal-btn-reset" id="resetPreviousData" title="Reset previous data">Reset Previous Data</button>
</div>
<div class="modal-btn-section">
<button class="modal-btn modal-btn-reset" id="resetUploadMetrics" title="Refreshes the tag list gathered from Latest Updates page">Reset upload Metrics</button>
</div>
<div class="modal-btn-section">
<button class="modal-btn modal-btn-reset" id="reset-data" title="Refreshes the tag list gathered from Latest Updates page">Reset data</button>
</div>
</details>
</div>
<hr class="thick-line" />
<div style="padding:10px;display:flex;justify-content:center;">
<button id="close-modal" class="modal-btn">🗙 Close</button>
</div>
</div>
`;
document.body.appendChild(modal);
//event listeners
setEventById("close-modal", closeModal);
setEventById("save-config", saveAndClose);
setEventById("user-alert", updateUserAlert);
setEventById("notification", updateNotification);
setEventById("config-visibility", updateConfigVisibility);
setEventById("resetPreviousData", resetPreviousData);
setEventById("resetNewGameLog", resetNewGameLog);
setEventById("resetUploadMetrics", resetUploadMetrics);
setEventById("reset-data", resetData);
setEventById("total-entries", updateTotalEntries);
setEventById("prefix-search", updateSearch, "input");
setEventById("prefix-search", updateSearchInput, 'focus');
setEventById("backup-data", backupData);
setEventById("check-manually", checkManually);
setEventById("manual-check", updateManualCheck);
setEventById("manual-check-interval", checkInputManualCheckInterval, "blur");
setEventById("restore-data", loadJsonBackup, "change");
//clicking outside
document.addEventListener('click', (e) => {
const input = document.getElementById('prefix-search');
const results = document.getElementById('search-results');
if (!input.contains(e.target) && !results.contains(e.target)) {
results.style.display = 'none';
}
});
modal.addEventListener("click", (e) => {
const content = modal.querySelector(".modal-content");
if (!content.contains(e.target)) {
closeModal();
}
});
}
function applyCustomCSS() {
const hasStyle =
document.head.lastElementChild.textContent.includes("#tag-config-button");
const customCSS = hasStyle ?
document.head.lastElementChild :
document.createElement("style");
customCSS.textContent = `
#restore-data {
width: 100%; /* fill the parent container */
max-width: 200px; /* optional, limit max size */
box-sizing: border-box;
}
#ignored-prefix-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.ignored-prefix-item {
display: inline-flex;
align-items: center;
padding: 4px 8px;
background-color: #333;
color: #fff;
border-radius: 4px;
font-size: 14px;
}
.ignored-prefix-item span {
margin-right: 6px;
}
.ignored-prefix-remove {
background-color: #c15858;
color: #fff;
border: none;
border-radius: 4px;
padding: 0 4px;
cursor: pointer;
font-size: 12px;
}
.ignored-prefix-remove:hover {
background-color: #a34040;
}
#prefix-search {
background-color: #222;
color: #fff;
border: 1px solid #555;
border-radius: 4px;
padding: 6px 8px;
width:100%;
}
#prefix-search:focus {
outline: none;
border: 1px solid #c15858;
}
/* Search results dropdown */
#search-results {
position: absolute;
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background-color: #222; /* same as inputs */
border: 1px solid #555; /* same border as input */
border-radius: 4px;
margin: 2px 0 0 0; /* small gap below input */
padding: 0;
list-style: none;
display: none;
z-index: 1000;
box-shadow: 0 4px 8px rgba(0,0,0,0.5); /* subtle shadow */
}
/* Individual list items */
#search-results li {
padding: 6px 8px;
cursor: pointer;
color: #fff;
background-color: #222;
}
#search-results li:hover {
background-color: #333; /* slightly lighter on hover */
}
/* All text inputs, textareas, selects */
#tag-config-modal input,
#tag-config-modal textarea,
#tag-config-modal select {
background-color: #222;
color: #fff;
border: 1px solid #555;
border-radius: 4px;
}
#tag-config-modal input:focus,
#tag-config-modal textarea:focus,
#tag-config-modal select:focus {
outline: none;
border: 1px solid #c15858;
}
/* Checkboxes and radios */
#tag-config-modal input[type="checkbox"],
#tag-config-modal input[type="radio"] {
accent-color: #c15858;
background-color: #222;
border: 1px solid #555;
}
#tag-config-modal .config-color-input {
border: 2px solid #3f4043;
border-radius: 5px;
padding: 2px;
width: 40px;
height: 28px;
cursor: pointer;
background-color: #181a1d;
}
#tag-config-modal .config-color-input::-webkit-color-swatch-wrapper {
padding: 0;
}
#tag-config-modal .config-color-input::-webkit-color-swatch {
border-radius: 4px;
border: none;
}
.modal-btn {
background-color: #893839;
color: white;
border: 2px solid #893839;
border-radius: 6px;
padding: 8px 16px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: background-color 0.3s ease, border-color 0.3s ease;
box-shadow: 0 4px 8px rgba(137, 56, 56, 0.5);
}
.modal-btn:hover {
background-color: #b94f4f;
border-color: #b94f4f;
}
.modal-btn:active {
background-color: #6e2b2b;
border-color: #6e2b2b;
box-shadow: none;
}
.config-row {
display: flex;
gap: 10px;
margin-bottom: 8px;
}
.config-row label {
flex-shrink: 0;
width: 140px;
/* fixed width for all labels */
text-align: left;
user-select: none;
}
.config-row input[type="checkbox"],
.config-row input[type="color"],
.config-row input[type="number"],
.config-row select {
flex-grow: 1;
}
#tag-config-button {
position: fixed;
bottom: 20px;
right: 20px;
left: 20px;
padding: 8px 12px;
font-size: 20px;
z-index: 7;
cursor: pointer;
border: 2px inset #461616;
background: #cc3131;
color: white;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
max-width: 70px;
width: auto;
opacity: 0.75;
transition: opacity 0.2s ease, transform 0.2s ease;
@media (width < 480px) {
bottom: 60px;
}
}
/* Hover effect */
#tag-config-button:hover {
opacity: 1;
}
#tag-config-button:active {
transform: scale(0.9);
}
#tag-config-button.hidden {
opacity: 0;
pointer-events: auto;
transition: opacity 0.3s ease;
}
#tag-config-button.hidden:hover {
opacity: 0.75;
}
#tag-config-modal .modal-content {
background: black;
border-radius: 10px;
min-width: 300px;
max-height: 80vh;
overflow-y: scroll;
/* always show vertical scrollbar */
}
#tag-config-modal.show {
display: flex;
}
.config-list-details {
overflow: hidden;
transition: border-width 1s, max-height 1s ease;
max-height: 40px;
}
.config-list-details[open] {
border-width: 2px;
max-height: 1300px;
}
.config-list-details summary {
text-align: center;
background: #353535;
border-radius: 8px;
padding-top: 5px;
padding-bottom: 5px;
cursor: pointer;
}
.thick-line {
border: none;
height: 1px;
background-color: #3f4043;
}
.custom-overlay-reason {
position: absolute;
top: 4px;
left: 4px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
z-index: 2;
pointer-events: none;
}
.resource-tile_thumb-wrap {
position: relative;
}
.tagItem,
.config-tag-item {
border-radius: 8px;
}
.config-tag-item {
margin-left: 5px;
cursor: pointer;
}
#modal-background-save,
#modal-background-close {
background: black;
position: absolute;
width: 50vw;
height: 100vh;
z-index: -1;
top: 0;
cursor: pointer;
opacity: 0.2;
transition: 0.2s opacity;
&:hover {
opacity: 0.5;
}
}
#modal-save-text,
#modal-close-text {
position: absolute;
z-index: -1;
font-size: 4em;
color: white;
font-weight: bolder;
margin: 0;
top: 0;
transition: 0.2s opacity;
opacity: 1;
&:hover {
cursor: pointer;
opacity: 0.8;
}
}
#modal-save-text {
left: 5vw;
}
#modal-close-text {
right: 5vw;
}
#modal-background-save {
border-right: 1px solid white;
left: 0;
}
#modal-background-close {
border-left: 1px solid white;
right: 0;
}
.modal-btn-section {
text-align: center;
}
.modal-btn-reset {
margin-top: 10px;
}
#tag-list {
list-style: none;
text-align: center;
margin: 0;
display: flex;
justify-content: start;
flex-wrap: wrap;
gap: 5px;
}
.modal-list-padding {
padding: 15px 10px 0 10px;
}
.modal-settings-spacing {
padding: 10px;
}
`;
document.head.appendChild(customCSS);
}
/** ----------------------------
* LISTENERS FUNCTIONS
* ---------------------------- */
function updateUserAlert(event) {
currentData.userAlert = event.target.checked;
}
function updateNotification(event) {
currentData.notification = event.target.checked;
}
function updateTotalEntries(event) {
currentData.totalEntries = event.target.checked;
}
function updateConfigVisibility(event) {
currentData.configVisibility = event.target.checked;
}
function resetPreviousData(event) {
if (confirm("Are you sure you want to reset Previous Data?")) {
currentData.previousData.length = 0;
saveDatas(currentData);
handleNewThreads();
renderUI();
}
}
function resetNewGameLog(event) {
if (confirm("Are you sure you want to reset New Game Log?")) {
currentData.newGameLog.length = 0;
saveDatas(currentData);
rewriteLatestUpdate(0);
renderUI();
}
}
function resetUploadMetrics(event) {
if (confirm("Are you sure you want to reset Upload Metrics?")) {
currentData.uploadTimestamps.length = 0;
saveDatas(currentData);
handleNewThreads();
renderUI();
}
}
function resetData(event) {
if (confirm("⚠️ This will reset ALL data to defaults. Continue?")) {
currentData = JSON.parse(JSON.stringify(defaultData));
saveDatas(currentData);
handleNewThreads();
renderUI();
}
}
function updateSearch(event) {
const query = event.target.value.toLowerCase(); // <-- correct
const filtered = currentData.knownPrefixes.filter(prefix =>
prefix.toLowerCase().includes(query)
);
renderList(filtered);
}
function updateSearchInput(event) {
if (event.target.value === '') renderList(currentData.knownPrefixes);
}
function checkManually() {
const btn = document.querySelector(".brmsConfigBtn.brmsRefresh a.brmsIcoRefresh");
if (btn) {
btn.click();
document.getElementById("new-game-info").innerHTML = "New game checked";
} else console.warn("Refresh button not found!");
}
function checkInputManualCheckInterval(event) {
const input = event.target;
let val = parseInt(input.value, 10);
// If blank, 0, or negative, reset to 1
if (!val || val < 1) {
input.value = 1;
val = 1;
// Optional visual feedback
input.style.borderColor = "red";
setTimeout(() => input.style.borderColor = "", 300);
}
currentData.manualCheckInterval = val;
}
function updateManualCheck(event) {
currentData.manualCheck = event.target.checked;
manualCheckInit();
}
async function loadJsonBackup(event) {
const file = event.target.files[0];
if (!file) return;
const text = await file.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch (e) {
alert("Invalid JSON file.");
return;
}
restoreData(parsed);
}
/** ----------------------------
* UI CONTROL
* ---------------------------- */
function openModal() {
if (!modalInjected) injectModal();
document.getElementById("tag-config-modal").style.display = "block";
renderUI();
renderMetrics();
}
function closeModal() {
document.getElementById("tag-config-modal").style.display = "none";
}
function saveAndClose() {
updateButtonVisibility();
saveDatas(currentData);
reload();
closeModal();
}
function renderUI() {
({
isThreadSafe,
isAlertSafe
} = safetyCheck());
if (!isThreadSafe || !isAlertSafe) {
updateErrorMsg();
}
const restoreInput = document.getElementById("restore-data");
if (restoreInput) restoreInput.value = "";
const userAlertEl = document.getElementById("user-alert");
if (userAlertEl) userAlertEl.checked = !!currentData.userAlert;
const newGameInfoEl = document.getElementById("new-game-info");
if (newGameInfoEl) newGameInfoEl.innerHTML = "";
const notificationEl = document.getElementById("notification");
if (notificationEl) notificationEl.checked = !!currentData.notification;
const configVisibilityEl = document.getElementById("config-visibility");
if (configVisibilityEl) configVisibilityEl.checked = !!currentData.configVisibility;
const manualCheckEl = document.getElementById("manual-check");
if (manualCheckEl) manualCheckEl.checked = !!currentData.manualCheck;
const manualCheckIntervalEl = document.getElementById("manual-check-interval");
if (manualCheckIntervalEl) manualCheckIntervalEl.value = parseInt(currentData.manualCheckInterval) || 1;
renderNewGameLog();
renderIgnoredPrefixes();
const warningMsgEl = document.getElementById("modal-warning-msg");
if (warningMsgEl) warningMsgEl.innerHTML = errorMsg || "";
}
function renderNewGameLog(containerId = "new-game-list-container") {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = ""; // clear first
const games = currentData.newGameLog;
if (!games || games.length === 0) {
container.textContent = "No new game yet";
return;
}
games.forEach((item, index) => {
const title = item.title || "Untitled";
// Create container div for each game
const wrapper = document.createElement("div");
wrapper.style.display = "flex";
wrapper.style.alignItems = "center";
wrapper.style.justifyContent = "space-between";
wrapper.style.marginBottom = "3px";
// Game title as link
if (item.link) {
const linkEl = document.createElement("a");
linkEl.href = item.link;
linkEl.target = "_blank";
linkEl.rel = "noopener noreferrer";
linkEl.textContent = title;
wrapper.appendChild(linkEl);
} else {
const textEl = document.createTextNode(title);
wrapper.appendChild(textEl);
}
// Remove/X button
const removeBtn = document.createElement("button");
removeBtn.textContent = "✖";
removeBtn.style.marginLeft = "8px";
removeBtn.style.cursor = "pointer";
removeBtn.title = "Remove this game from the list";
removeBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
renderUI;
currentData.newGameLog.splice(index, 1);
saveDatas(currentData);
renderNewGameLog(containerId); // re-render to update UI
});
wrapper.appendChild(removeBtn);
container.appendChild(wrapper);
});
}
function renderMetrics() {
document.getElementById("upload-frequency").innerHTML = analyzeUploadFrequencies().replace(/\n/g, "<br>");
document.getElementById("metric-prev").textContent = currentData.previousData.length;
document.getElementById("metric-newgame").textContent = currentData.newGameLog.length;
document.getElementById("total-entries").value = currentData.totalEntries;
// only count timestamps from the last 30 days
const cutoff = Math.floor(Date.now() / 1000) - (30 * 24 * 60 * 60);
const recentUploads = currentData.uploadTimestamps.filter(ts => ts >= cutoff);
document.getElementById("metric-upload").textContent = recentUploads.length;
}
/** ----------------------------
* utility
* ---------------------------- */
function renderIgnoredPrefixes() {
const container = document.getElementById('ignored-prefix-list');
if (!container) return;
container.innerHTML = ''; // clear previous
currentData.ignoredPrefix.forEach((prefix, index) => {
const item = document.createElement('div');
item.classList.add('ignored-prefix-item');
const text = document.createElement('span');
text.textContent = prefix;
const removeBtn = document.createElement('button');
removeBtn.textContent = 'X';
removeBtn.classList.add('ignored-prefix-remove');
removeBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
currentData.ignoredPrefix.splice(index, 1);
saveDatas(currentData);
renderIgnoredPrefixes(currentData);
});
item.appendChild(text);
item.appendChild(removeBtn);
container.appendChild(item);
});
}
function manualCheckInit() {
// Clear any existing timer
if (manualCheckTimer) clearInterval(manualCheckTimer);
// Only start if manualCheck is enabled
if (!currentData.manualCheck) return;
const intervalMs = Math.max(1, parseInt(currentData.manualCheckInterval)) * 60 * 1000; // convert minutes to ms
console.log(intervalMs);
manualCheckTimer = setInterval(() => {
({
isThreadSafe,
isAlertSafe
} = safetyCheck());
if (isThreadSafe) {
console.warn("Thread observer cannot run: thread container not ready or inaccessible.");
return;
}
// Or simulate a click on the refresh button
const refreshBtn = document.querySelector(".brmsConfigBtn.brmsRefresh a.brmsIcoRefresh");
if (refreshBtn) refreshBtn.click();
// You can trigger the thread observer manually
const container = document.querySelector('.brmsTabContent_2 ol.brmsContentList');
if (container) {
handleNewThreads();
}
}, intervalMs);
console.log("Manual check initiated");
}
// Helper to render the filtered list
function renderList(filtered) {
const input = document.getElementById('prefix-search');
const results = document.getElementById('search-results');
results.innerHTML = '';
// remove already ignored items
const visibleItems = filtered.filter(item => !currentData.ignoredPrefix.includes(item));
if (visibleItems.length === 0) {
results.style.display = 'none';
return;
}
visibleItems.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
li.classList.add('search-result-item');
li.addEventListener('click', () => {
// Add to ignored list if not already present
currentData.ignoredPrefix.push(item);
renderIgnoredPrefixes();
saveDatas(currentData);
// Reset input and hide results
input.value = '';
results.style.display = 'none';
});
results.appendChild(li);
});
results.style.display = 'block';
}
function updateButtonVisibility() {
const button = document.getElementById("tag-config-button");
if (!button) return;
if (currentData.configVisibility === false) {
// Blink 3 times
let blinkCount = 0;
const maxBlinks = 3;
const blinkInterval = 400; // ms
if (button.blinkIntervalId) {
clearInterval(button.blinkIntervalId);
}
button.classList.add("hidden");
button.blinkIntervalId = setInterval(() => {
button.classList.toggle("hidden");
blinkCount++;
if (blinkCount >= maxBlinks * 2) {
clearInterval(button.blinkIntervalId);
button.classList.add("hidden");
button.blinkIntervalId = undefined;
}
}, blinkInterval);
} else {
// Show button normally
if (button.blinkIntervalId) {
clearInterval(button.blinkIntervalId);
button.blinkIntervalId = undefined;
}
button.classList.remove("hidden");
}
}
//Alert
function checkAlert() {
const alertLink = document.querySelector('.p-navgroup-link--alerts');
const badgeValue = alertLink.getAttribute('data-badge');
return badgeValue;
}
/** ----------------------------
* analyze upload pattern
* ---------------------------- */
function filterRecentTimestamps(timestamps, days = 30) {
const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000;
return timestamps.filter(ts => ts * 1000 >= cutoffTime);
}
function countUploads(timestamps) {
const hourCounter = Array(24).fill(0);
const dailyCounter = {};
const activeDays = new Set();
for (const ts of timestamps) {
const dt = new Date(ts * 1000);
const dateStr = dt.toISOString().split("T")[0];
hourCounter[dt.getHours()]++;
dailyCounter[dateStr] = (dailyCounter[dateStr] || 0) + 1;
activeDays.add(dateStr);
}
return {
hourCounter,
dailyCounter,
activeDays
};
}
function getExtremeDays(dailyCounter) {
const sortedDates = Object.keys(dailyCounter).sort();
if (sortedDates.length <= 2) return {
highest: null,
lowest: null
};
const filtered = Object.fromEntries(
Object.entries(dailyCounter).filter(([date]) => date !== sortedDates[0] && date !== sortedDates[sortedDates.length - 1])
);
const [highestDate, highest] = Object.entries(filtered).reduce((a, b) => b[1] > a[1] ? b : a);
const [lowestDate, lowest] = Object.entries(filtered).reduce((a, b) => b[1] < a[1] ? b : a);
return {
highestDate,
highest,
lowestDate,
lowest
};
}
function calculateDailyAverage(totalUploads, activeDays) {
const totalDays = activeDays.size > 1 ? activeDays.size - 1 : 1;
return totalUploads / totalDays;
}
function calculateDailyAverage(totalUploads, activeDays) {
const totalDays = activeDays.size > 1 ? activeDays.size - 1 : 1;
return totalUploads / totalDays;
}
function calculateCurrentHourProbability(hourCounter) {
if (!hourCounter || hourCounter.length !== 24) return 0;
const totalUploads = hourCounter.reduce((sum, count) => sum + count, 0);
if (totalUploads === 0) return 0;
const currentHour = new Date().getHours();
const uploadsThisHour = hourCounter[currentHour];
return uploadsThisHour / totalUploads;
}
function analyzeUploadFrequencies() {
const recentTimestamps = filterRecentTimestamps(currentData.uploadTimestamps);
const {
hourCounter,
dailyCounter,
activeDays
} = countUploads(recentTimestamps);
const todayDateStr = new Date().toISOString().split("T")[0];
const uploadsToday = dailyCounter[todayDateStr] || 0;
const {
highestDate,
highest,
lowestDate,
lowest
} = getExtremeDays(dailyCounter);
const dailyAverage = calculateDailyAverage(recentTimestamps.length, activeDays);
const probability = calculateCurrentHourProbability(hourCounter);
const result = [];
result.push("Upload frequency (last 30 days):");
result.push(`<div style="margin:0;">${makeSparklineTable(hourCounter)}</div>`);
result.push(makeLegend());
result.push(`\nDaily average: ${dailyAverage.toFixed(2)} uploads`);
result.push(`Today's uploads so far: ${uploadsToday}`);
result.push(`Probability of upload this hour: ${(probability * 100).toFixed(2)}%`);
if (uploadsToday < dailyAverage) {
const remainingEstimate = dailyAverage - uploadsToday;
result.push(`Estimated remaining uploads : ~${remainingEstimate.toFixed(2)}`);
} else {
result.push("Today's upload is above expected.");
}
if (highestDate) result.push(`📈 Highest total upload: ${highestDate} (${highest})`);
if (lowestDate) result.push(`📉 Lowest total upload: ${lowestDate} (${lowest})`);
return result.join("\n");
}
function makeSparklineTable(hourCounter) {
const blocks = "▁▂▃▅▆▇▉█";
const max = Math.max(...hourCounter, 1);
const sparklineCells = hourCounter.map((val, hour) => {
const level = Math.floor((val / max) * (blocks.length - 1));
const shade = Math.round((level / (blocks.length - 1)) * 100);
const color = `hsl(0, 60%, ${40 + shade * 0.4}%)`;
const tooltip = `${hour.toString().padStart(2,"0")}:00 — ${val} uploads`;
return `<td style="text-align:center;padding:0;margin:0;"><span style="color:${color}" title="${tooltip}">${blocks[level]}</span></td>`;
}).join("");
const labelCells = hourCounter.map((_, hour) => {
if ([0, 6, 12, 18, 23].includes(hour))
return `<td style="text-align:center;padding:0;margin:0;">${hour}</td>`;
return `<td style="text-align:center;padding:0;margin:0;">·</td>`;
}).join("");
// use template literals for readability, then remove newlines before returning
const html = `
<table cellspacing="0" cellpadding="0" style="border-collapse: collapse; font-family: monospace; text-align:center;">
<tr>${sparklineCells}</tr>
<tr>${labelCells}</tr>
</table>
`;
return html.replace(/\n\s*/g, ""); // remove all newlines and leading spaces
}
function makeLegend() {
const levels = ["▁", "▃", "▅", "█"];
const legend = levels.map((block, i) => {
const shade = Math.round((i / (levels.length - 1)) * 100);
const color = `hsl(0, 60%, ${40 + shade * 0.4}%)`; // same red gradient style
return `<span style="color:${color}">${block}</span>`;
}).join(" ");
return `Legend: ${legend} (low → high)<br>Hover to see the details.`;
}
// Ask the user for notification permission if not already granted
async function askForPermission() {
if (Notification.permission === "granted") {
return true;
}
if (Notification.permission === "denied") {
return false;
}
// Ask for permission
const permission = await Notification.requestPermission();
if (permission === "granted") {
console.log("Notification permission granted.");
return true;
} else {
console.log("Notification permission denied.");
return false;
}
}
// Fire a notification based on how many new games are in currentData.newGameLog
function fireUpNotif(title, body, icon = "") {
const notif = new Notification(title, {
body: body,
icon: icon // optional icon
});
setTimeout(() => notif.close(), 5000);
}
function showNewGameAlert() {
const count = currentData.newGameLog.length;
if (count > 0) {
fireUpNotif("New Game Alert 🎮", `You have ${count} not read new game(s).`, icon = "");
rewriteLatestUpdate(count);
}
}
function showNewAlert() {
const newAlertTotal = checkAlert();
if (newAlertTotal > curTotalAlert) {
curTotalAlert = newAlertTotal;
fireUpNotif("Account alert ", `You have ${curTotalAlert} not read notification(s).`, icon = "")
} else {
curTotalAlert = newAlertTotal;
}
}
function rewriteLatestUpdate(total) {
document.querySelector('a[data-nav-id="LatestUpdates"]').innerHTML = `Latest Updates (${total})`
}
function cleanNotif() {
currentData.newGameLog.length = 0;
saveDatas(currentData);
}
function setEventById(idSelector, callback, eventType = "click") {
document.getElementById(idSelector).addEventListener(eventType, callback);
}
function hijackLatestUpdate() {
const latestLink = document.querySelector('a[data-nav-id="LatestUpdates"]');
if (!latestLink) return;
latestLink.addEventListener('click', function(e) {
e.preventDefault();
cleanNotif();
rewriteLatestUpdate(0);
window.open(this.href, '_blank');
});
}
function getCurrentDate() {
const now = Math.floor(Date.now() / 1000);
const result = now - (30 * 24 * 60 * 60);
return result;
}
/** ----------------------------
* MAIN FUNCTIONS
* ---------------------------- */
function checkPrefix(prefixes) {
let isNewPrefix = false;
prefixes.forEach(prefix => {
const exists = currentData.knownPrefixes.some(
known => known.toLowerCase() === prefix.toLowerCase()
);
if (!exists) {
currentData.knownPrefixes.push(prefix);
isNewPrefix = true;
}
});
isNewPrefix && saveDatas(currentData);
}
function getNewData() {
const items = document.querySelectorAll('.brmsTabContent_2 li.itemThread');
const threadData = [];
items.forEach(li => {
// Grab the main thread link
const a = li.querySelector('.listBlock.itemTitle a[href*="/threads/"]');
if (!a) return;
const spansText = Array.from(a.querySelectorAll("span"))
.map(el => el.textContent.trim())
.filter(p => p.length > 0);
checkPrefix(spansText);
if (currentData.ignoredPrefix.some(tag =>
spansText.some(span => span.toLowerCase() === tag.toLowerCase())
)) return;
const idMatch = a.href.match(/\.([0-9]+)(\/|$)/);
if (!idMatch) return;
const threadId = idMatch[1];
const title = Array.from(a.childNodes)
.filter(node => node.nodeType === Node.TEXT_NODE)
.map(node => node.textContent.trim())
.filter(text => text.length > 0)
.join(" ");
// Grab the timestamp element
const timeEl = li.querySelector('.listBlock.itemDetail.itemDetailDate time.u-dt');
let timestamp = null;
if (timeEl) {
timestamp = Math.floor(new Date(timeEl.getAttribute('datetime')).getTime() / 1000);
}
threadData.push({
id: threadId,
title,
link: a.href, // <<< store the original link here
timestamp
});
});
threadData.sort((a, b) => a.timestamp - b.timestamp);
debug && console.log(threadData);
return threadData;
}
// ---- THREAD OBSERVER ----
function startThreadObserver() {
const container = document.querySelector('.brmsTabContent_2 ol.brmsContentList');
if (!container) {
setTimeout(startThreadObserver, 300);
return;
}
console.log("thread observer initiated");
// Check existing nodes on first run
if (container.querySelector('li.itemThread')) {
handleNewThreads();
}
const observer = new MutationObserver((mutationsList) => {
({
isThreadSafe,
isAlertSafe
} = safetyCheck());
if (!isThreadSafe) {
console.warn("Thread observer cannot run: thread container not ready or inaccessible.");
return;
}
let newThreadsDetected = false;
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1 && node.matches('li.itemThread')) {
newThreadsDetected = true;
break;
}
}
}
}
if (newThreadsDetected) {
handleNewThreads();
}
});
// Watch direct children + subtree (in case threads get wrapped inside another element)
observer.observe(container, {
childList: true,
subtree: true
});
}
// ---- ALERT OBSERVER ----
function startAlertObserver() {
const alertLink = document.querySelector('.p-navgroup-link--alerts');
if (!alertLink) return;
const observer = new MutationObserver(() => {
({
isThreadSafe,
isAlertSafe
} = safetyCheck());
if (isAlertSafe) {
console.warn("Thread observer cannot run: thread container not ready or inaccessible.");
return;
}
currentData.userAlert && showNewAlert();
});
observer.observe(alertLink, {
attributes: true,
attributeFilter: ['data-badge']
});
}
function handleNewThreads() {
const items = document.querySelectorAll('.brmsTabContent_2 li.itemThread');
const newDatas = getNewData();
checkData(newDatas);
}
let gettingData = false;
function checkData(data) {
if (gettingData) return;
gettingData = true;
const cutoff = getCurrentDate();
data.forEach(item => {
const {
id,
title,
link,
timestamp
} = item;
// Skip if timestamp too old
if (!timestamp || timestamp < cutoff) return;
// Skip if ID already exists
if (currentData.previousData.includes(id)) return;
newDataExist = true;
newGameNotif = true;
// Enforce max entries
if (currentData.previousData.length >= currentData.totalEntries) {
currentData.previousData.shift(); // drop oldest id
currentData.newGameLog.shift(); // drop oldest {title, link}
currentData.uploadTimestamps.shift(); // drop oldest timestamp
}
// Add new entry
currentData.previousData.push(id);
currentData.newGameLog.push({
title,
link
}); // store as object
currentData.uploadTimestamps.push(timestamp);
});
// Clean up old timestamps
while (currentData.uploadTimestamps.length > 0 && currentData.uploadTimestamps[0] < cutoff) {
currentData.previousData.shift();
currentData.newGameLog.shift();
currentData.uploadTimestamps.shift();
}
debug && console.log(currentData);
if (newDataExist) {
newDataExist = false;
saveDatas(currentData);
}
if (currentData.notification && (newGameNotif || firstNotif)) {
firstNotif = false;
newGameNotif = false;
isNotifAllowed && showNewGameAlert();
}
gettingData = false;
}
//safety
function safetyCheck() {
// Thread tab <li> must exist and have 'current' class
const threadTabLi = document.querySelector('li.brmlShow[data-tabid="2"], li.brmlShow.current');
// Optional: you can make the selector more precise if needed
const threadTab = document.querySelector('[data-tabid="2"]');
// Thread entry must exist
const threadEntry = document.querySelector(`.brmsNumberEntry[data-limit="${currentData.totalEntries}"]`);
// Alert tab
const alertTab = document.querySelector('.p-navgroup-link--alerts');
if (threadTabLi && threadEntry) threadLostFocus = !threadTabLi.classList.contains('current');
// Thread safe if both the tab is visible/current and the entry exists
const isThreadSafe = !!(threadTabLi && threadEntry && threadTabLi.classList.contains('current'));
return {
isThreadSafe,
isAlertSafe: !!alertTab
};
}
//init script
function waitForBody(callback) {
if (document.body) {
callback();
} else {
requestAnimationFrame(() => waitForBody(callback));
}
}
function updateErrorMsg() {
errorMsg = ""; // reset each call
const notifBlocked = !isNotifAllowed && (currentData.notification || currentData.userAlert);
const threadObserverFailed = !isObserverThreadInit;
const alertObserverFailed = !isObserverAlertInit;
const threadNotFocused = threadLostFocus;
const threadUnsafe = !isThreadSafe;
const alertUnsafe = !isAlertSafe;
if (notifBlocked || threadObserverFailed || alertObserverFailed || threadNotFocused || threadUnsafe || alertUnsafe) {
errorMsg = `<div style="border:1px solid #c15858; background:#ffe5e5; padding:10px; border-radius:5px; color:#a94442; font-weight:bold;">
⚠️ <span style="text-decoration:underline;">Detected issues:</span>
<ul style="margin:5px 0 0 20px; padding:0; font-weight:normal; color:#5a2121;">
`;
if (notifBlocked) {
errorMsg += `<li>Notifications are blocked. Alerts will not be shown.</li>`;
}
if (threadObserverFailed) {
errorMsg += `<li>Thread observer failed to initialize. You will not receive thread updates.</li>`;
}
if (alertObserverFailed) {
errorMsg += `<li>Alert observer failed to initialize. You will not receive alerts.</li>`;
}
if (threadNotFocused) {
errorMsg += `<li>Thread tab is not focused. Refresh the page or click on 'Latest Updates' below the site feedback menu.</li>`;
}
errorMsg += `</ul>
<div style="margin-top:5px;">Try logging in and refreshing the page.</div>
</div>`;
}
}
waitForBody(async () => {
const threadTab = document.querySelector('[data-tabid="2"]');
if (threadTab) threadTab.click();
currentData = await loadData();
({
isThreadSafe,
isAlertSafe
} = safetyCheck());
if (isThreadSafe) {
isObserverAlertInit = true;
const entry = document.querySelector(`.brmsNumberEntry[data-limit="${currentData.totalEntries}"]`);
if (entry) {
entry.dispatchEvent(new MouseEvent("click", {
bubbles: true
}));
entry.blur();
const menu = entry.closest('.brmsLimitList')?.querySelector('.brmsDropdownMenu');
if (menu) menu.style.display = 'none';
document.body.focus();
}
startThreadObserver();
manualCheckInit();
}
await reload();
injectButton();
applyCustomCSS();
hijackLatestUpdate();
updateButtonVisibility();
if (isAlertSafe) {
isObserverThreadInit = true;
console.log("alert observer initiated");
startAlertObserver();
}
updateErrorMsg();
});
})();