Highlight post age, user rating/muting, profile cards, alternative table view
// ==UserScript==
// @name Transfermarkt: Forum Utils
// @namespace sorenGu
// @match https://www.transfermarkt.de/*
// @grant none
// @version 1.1
// @author sorenGu
// @license MIT
// @description Highlight post age, user rating/muting, profile cards, alternative table view
// ==/UserScript==
(function() {
'use strict';
class DateHighlighter {
start() {
// Run on load + observe changes
this.process();
const observer = new MutationObserver(() => this.process());
observer.observe(document.body, { childList: true, subtree: true });
}
process() {
const nodes = document.querySelectorAll(".post-header-datum, .post-datum");
const now = Date.now();
nodes.forEach(el => {
const text = el.textContent.trim();
const date = this.parseGermanDate(text);
if (!date) return;
const age_milliseconds = now - date.getTime();
this.applyHighlight(el, age_milliseconds);
});
}
applyHighlight(el, age_milliseconds) {
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
let color = "";
// --- New granular green intervals ---
if (age_milliseconds < 5 * minute) {
color = "#00ff5d";
} else if (age_milliseconds < 15 * minute) {
color = "#26aa1a";
} else if (age_milliseconds < 30 * minute) {
color = "#43afe1";
} else if (age_milliseconds < hour) {
color = "#4987f8";
}
else if (age_milliseconds < day / 2) {
color = "#ffe300";
} else if (age_milliseconds < day) {
color = "#f86300";
} else {
return;
}
el.style.backgroundColor = this.hexToRgba(color, 0.4);
el.style.borderRadius = "5px"
}
parseGermanDate(str) {
// Example: "14.11.2025 - 09:05 Uhr"
const match = str.match(/(\d{2})\.(\d{2})\.(\d{4}).*?(\d{2}):(\d{2})/);
if (!match) return null;
const [_, day, month, year, hour, minute] = match;
return new Date(`${year}-${month}-${day}T${hour}:${minute}:00`);
}
hexToRgba(hex, alpha) {
let r = parseInt(hex.slice(1, 3), 16);
let g = parseInt(hex.slice(3, 5), 16);
let b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
}
class PlayerProfileDisplayer {
start() {
if (!/thread|forum/i.test(location.pathname)) {
return;
}
this.addCSS()
const nodes = document.querySelectorAll("#postList .items .box a");
nodes.forEach(el => {
if (!el.textContent) return;
const parentElement = el.parentElement;
if (parentElement.classList.contains("formation-player-portrait")
|| parentElement.classList.contains("eleven-player-names")
|| parentElement.classList.contains("forum-post-noten-note")) {
return;
}
this.replaceLinkWithProfile(el);
});
}
async replaceLinkWithProfile(el) {
// first try to get link from href else use textContent
const href = el.getAttribute('href');
const link = href ? href : el.textContent;
const extracted= this.extractFromLink(link);
if (!extracted) return;
const {name, playerId} = extracted;
const profileUrl = `https://www.transfermarkt.de/${name}/profil/spieler/${playerId}`;
let playerDoc = await this.fetchDocument(profileUrl);
const playerData = this.extractPlayerData(playerDoc);
console.log(playerData);
const card = this.buildPlayerCard(playerData, profileUrl, el.textContent);
el.replaceWith(card);
}
extractFromLink(link) {
const match = link.match(/\/([\w+-]+)\/.*?\/spieler\/(\d+)/);
if (!match) return null;
const [, name, playerId] = match;
return {name, playerId};
}
extractPlayerData(doc) {
const name = doc.querySelector('h1.data-header__headline-wrapper');
const image = doc.querySelector('img.data-header__profile-image');
const valueEl = doc.querySelector(".data-header__market-value-wrapper");
const lastUpdateEl = valueEl.querySelector('.data-header__last-update');
if (lastUpdateEl) lastUpdateEl.remove();
const value = valueEl.innerText
const playerData = this._extractPlayerData(doc);
const teamData = this._extractTeamData(doc);
return {name, image, value, playerData, teamData};
}
_extractPlayerData(doc) {
const contents = doc
.querySelector(".data-header__info-box > .data-header__details")
.querySelectorAll(".data-header__content");
return {
birthDate: contents[0].textContent.trim(),
nationality: contents[2],
height: contents[3].textContent.trim(),
position: contents[4].textContent.trim()
};
}
_extractTeamData(doc) {
const teamDataContainer = doc.querySelector('.data-header__box--big');
const _teamData = teamDataContainer.querySelector('.data-header__club-info');
const orderedTeamData = _teamData.querySelectorAll(".data-header__content")
const leagueLocation = orderedTeamData[0];
return {
name: _teamData.querySelector(".data-header__club").innerHTML,
league: _teamData.querySelector(".data-header__league").innerHTML,
leagueLocation: leagueLocation.innerHTML.replace(/Liga/, ''),
image: teamDataContainer.querySelector("a.data-header__box__club-link"),
contract_start: orderedTeamData[1].textContent.trim(),
contract_end: orderedTeamData[2].textContent.trim(),
}
}
async fetchDocument(url) {
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) {
throw new Error(`Fetch failed: ${url}`);
}
const html = await res.text();
return new DOMParser().parseFromString(html, 'text/html');
}
buildPlayerCard(data, profileUrl, originalUrl) {
const card = document.createElement("div");
card.className = "player-card";
card.innerHTML = `
<div class="player-card__content flex-row flex-align-center">
<div class="player-card__image">
${data.image.outerHTML}
</div>
<div class="flex-column">
${data.name.outerHTML}
<div class="flex-row inner-gap">
<div class="player-card__details">
<p>${data.playerData.birthDate}</p>
<p>${data.playerData.nationality.outerHTML}</p>
<p>${data.playerData.height}</p>
<p>${data.playerData.position}</p>
</div>
<div class="player-card__extra">
<p>${data.value}</p>
<p><a href="${profileUrl}">Profile</a></p>
${profileUrl !== originalUrl ? `<p><a href="${originalUrl}">Original Link</a></p>` : ''}
</div>
<div class="player-card__team">
<p>${data.teamData.image.outerHTML} ${data.teamData.name}</p>
<p>${data.teamData.league} (${data.teamData.leagueLocation})</p>
<p>${data.teamData.contract_start}</p>
<p>- ${data.teamData.contract_end}</p>
</div>
</div>
</div>
</div>
`;
return card;
}
addCSS() {
const style = document.createElement('style');
style.textContent = `
.flex-row {
display: flex;
flex-direction: row;
gap: 1rem;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-align-center {
align-items: center;
}
.player-card h1 {
margin: 0;
padding-bottom: 0.5rem;
}
.player-card {
border: 1px solid #ccc;
padding: .5rem;
border-radius: 0.5rem;
}
.player-card__image {
height: 7rem;
}
.player-card__image .data-header__profile-image {
height: 100%;
width: auto;
}
.inner-gap {
gap: 3rem;
}
.player-card__team .data-header__box__club-link img {
height: 1rem;
width: auto;
}
`
document.head.appendChild(style);
}
}
class UserRating {
constructor() {
this.load();
}
load() {
this.users_rating = JSON.parse(localStorage.getItem('users_rating') || "{}");
}
start() {
this.addCSS()
const nodes = document.querySelectorAll(".post-userdaten");
nodes.forEach(node => {
try {
this.addToUserElement(node)
} catch (e) {}
});
const quotes = document.querySelectorAll(".quote>b");
quotes.forEach(node => {
try {
this.styleQuote(node)
} catch (e) {}
});
}
addButton(label, callback, parent, classes = []) {
const btn = document.createElement('button');
btn.textContent = label;
btn.classList.add(...classes);
btn.addEventListener('click', callback);
parent.appendChild(btn);
return btn;
}
addText(text, parent, classes = []) {
const el = document.createElement('div');
el.textContent = text;
el.classList.add(...classes);
parent.appendChild(el);
return el;
}
save() {
localStorage.setItem('users_rating', JSON.stringify(this.users_rating))
}
addCSS() {
const style = document.createElement('style');
style.textContent = `
`
document.head.appendChild(style);
}
addToUserElement(node) {
const userInfo = node.querySelector(".forum-user-info");
const userLink = userInfo.querySelector("a.forum-user");
const userId = this.getUserId(userLink);
const userRating = this.users_rating[userId] || {"-": 0, "+": 0}
const extraData = node.querySelector(".forum-user-info-data");
const displayUserRating = this.addText("", extraData)
this.addButton("+", () => {
this.changeRating(userId, "+", updateDisplay);
}, extraData)
this.addButton("-", () => {
this.changeRating(userId, "-", updateDisplay);
}, extraData)
const self = this;
function updateDisplay(userRating) {
displayUserRating.innerText = `Rating: ${userRating["+"]}+/${userRating["-"]}-`
self.handleHidingAndStyling(userRating, node, userLink.textContent.trim());
}
updateDisplay(userRating);
}
handleHidingAndStyling(userRating, node, username) {
UserRating.addStylingForUser(userRating, node);
const score = userRating["+"] - userRating["-"];
const box = node.closest('.box');
if (!box) return;
const boxContent = box.querySelector('.box-content');
if (!boxContent) return;
// Remove existing notice if any
const existingNotice = box.querySelector('.score-hidden-notice');
if (existingNotice) existingNotice.remove();
if (score <= -10) {
boxContent.style.display = 'none';
const notice = document.createElement('div');
notice.className = 'score-hidden-notice';
notice.style.padding = '10px';
notice.style.background = '#eee';
notice.style.textAlign = 'center';
notice.innerHTML = `
Commenter: ${username} (Score: ${score})
<a href="#" class="show-comment-btn" style="margin-left: 10px; color: blue; text-decoration: underline; cursor: pointer;">Show Comment</a>
`;
boxContent.parentNode.insertBefore(notice, boxContent);
notice.querySelector('.show-comment-btn').addEventListener('click', (e) => {
e.preventDefault();
boxContent.style.display = 'block';
notice.style.display = 'none';
this.addHideButton(boxContent, notice);
});
} else {
boxContent.style.display = 'block';
}
}
addHideButton(boxContent, notice) {
if (boxContent.querySelector('.hide-comment-btn-container')) return;
const hideBtnContainer = document.createElement('div');
hideBtnContainer.className = 'hide-comment-btn-container';
hideBtnContainer.style.padding = '5px';
hideBtnContainer.style.textAlign = 'right';
hideBtnContainer.innerHTML = `<a href="#" style="color: blue; text-decoration: underline; cursor: pointer;">Hide Comment</a>`;
boxContent.appendChild(hideBtnContainer);
hideBtnContainer.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
boxContent.style.display = 'none';
notice.style.display = 'block';
});
}
changeRating(userId, ratingKey, updateDisplay) {
this.load();
const _userRating = this.users_rating[userId] || {"-": 0, "+": 0}
_userRating[ratingKey] += 1;
updateDisplay(_userRating);
this.users_rating[userId] = _userRating;
this.save()
}
static addStylingForUser(userRating, node) {
const userRatingNumber = userRating["+"] - userRating["-"];
if (userRatingNumber === 0) return;
let backgroundColor = "#ffffff"
if (userRatingNumber > 0) {
backgroundColor = "rgba(123,213,92,0.14)"
}
if (userRatingNumber > 5) {
backgroundColor = "rgba(92,134,213,0.38)"
}
if (userRatingNumber < 0) {
backgroundColor = "rgba(213,136,92,0.14)"
}
if (userRatingNumber < -5) {
backgroundColor = "rgba(213,92,92,0.34)"
}
if (userRatingNumber < -9) {
backgroundColor = "rgb(213, 92, 194)"
}
node.style.backgroundColor = backgroundColor;
}
getUserId(userLink) {
return userLink.getAttribute("href").match(/\/(\d+)$/)[1];
}
styleQuote(node) {
const userLink = node.querySelector("a");
const userId = this.getUserId(userLink);
const userRating = this.users_rating[userId] || {"-": 0, "+": 0};
UserRating.addStylingForUser(userRating, node);
}
}
class AlternativeTable {
constructor() {
// this isnt loaded at the start, wait for it to be created
this.processedTables = new Set();
}
start() {
this.process();
const observer = new MutationObserver(() => this.process());
observer.observe(document.body, { childList: true, subtree: true });
}
process() {
const tables = document.querySelectorAll("#yw2");
tables.forEach(table => {
if (this.processedTables.has(table)) return;
const parentBox = table.closest(".box");
if (!parentBox) return;
const headline = parentBox.querySelector(".content-box-headline");
if (!headline || !headline.textContent.includes("Tabelle")) return;
if (headline.querySelector(".alternate-table-btn")) return;
const btn = document.createElement("button");
btn.textContent = "Convert Table";
btn.className = "alternate-table-btn";
btn.style.marginLeft = "10px";
btn.style.fontSize = "0.8rem";
btn.style.padding = "2px 5px";
btn.style.cursor = "pointer";
btn.addEventListener("click", (e) => {
e.preventDefault();
this.processedTables.add(table);
this.alternateTable(table);
btn.remove();
});
headline.appendChild(btn);
});
}
alternateTable(tableBody) {
const headerRows = tableBody.querySelectorAll("thead > tr");
const data = {}
headerRows.forEach(row => row.remove());
const tbody = tableBody.querySelector("tbody");
const tableRows = Array.from(tbody.querySelectorAll("tr"));
const teamDataMap = new Map(); // All teams by position to know the colors
tableRows.forEach(row => {
const cells = Array.from(row.querySelectorAll("td"));
const teamData = {
position: parseInt(cells[0]?.textContent.trim()),
backgroundColor: cells[0]?.style.backgroundColor,
icon: cells[1]?.querySelector("img")?.getAttribute("src"),
nameElement: cells[2]?.querySelector("a"),
goalDifference: cells[4]?.textContent.trim(),
points: parseInt(cells[5]?.textContent.trim()),
}
if (!data.hasOwnProperty(teamData.points)) {
data[teamData.points] = [];
}
data[teamData.points].push(teamData);
teamDataMap.set(teamData.position, teamData);
row.remove()
});
const dataKeys = Object.keys(data).map(Number).sort((a, b) => b - a);
const maxPoints = dataKeys[0];
const minPoints = dataKeys[dataKeys.length - 1];
const pointColors = {}; // points -> Set of colors
// 1. Initial assignment of colors based on teams at that point total
teamDataMap.forEach(team => {
if (team.backgroundColor) {
if (!pointColors[team.points]) pointColors[team.points] = new Set();
pointColors[team.points].add(team.backgroundColor);
}
});
// 2. Propagation Logic
// For first 14 positions: Color from the team up until another team is there
for (let i = 1; i <= 14; i++) {
const team = teamDataMap.get(i);
if (team && team.backgroundColor) {
const color = team.backgroundColor;
// Propagate up (to higher points)
for (let p = team.points + 1; p <= maxPoints; p++) {
// Stop if we hit a point total that already has a team with a color
const teamsAtP = data[p] || [];
if (teamsAtP.some(t => t.backgroundColor)) break;
if (!pointColors[p]) pointColors[p] = new Set();
pointColors[p].add(color);
}
}
}
// After 14: color from the team down
const totalTeams = teamDataMap.size;
for (let i = 15; i <= totalTeams; i++) {
const team = teamDataMap.get(i);
if (team && team.backgroundColor) {
const color = team.backgroundColor;
// Propagate down (to lower points)
for (let p = team.points - 1; p >= minPoints; p--) {
// Stop if we hit a point total that already has a team with a color
const teamsAtP = data[p] || [];
if (teamsAtP.some(t => t.backgroundColor)) break;
if (!pointColors[p]) pointColors[p] = new Set();
pointColors[p].add(color);
}
}
}
for (let points = maxPoints; points >= minPoints; points--) {
const tr = document.createElement("tr");
const pointsTd = document.createElement("td");
pointsTd.textContent = points;
if (points % 3 === 0) {
pointsTd.style.fontWeight = "bold";
} else {
pointsTd.style.fontWeight = "normal";
}
pointsTd.style.textAlign = "center";
// Apply colors
const colors = Array.from(pointColors[points] || []);
if (colors.length === 1) {
pointsTd.style.backgroundColor = colors[0];
} else if (colors.length > 1) {
const step = 100 / colors.length;
const gradientParts = colors.map((c, i) => `${c} ${i * step}%, ${c} ${(i + 1) * step}%`);
pointsTd.style.background = `linear-gradient(to bottom, ${gradientParts.join(", ")})`;
}
tr.appendChild(pointsTd);
const teamsTd = document.createElement("td");
teamsTd.setAttribute("colspan", "10");
tr.appendChild(teamsTd);
tbody.appendChild(tr);
if (!data.hasOwnProperty(points)) continue;
data[points].forEach(team => {
const teamSpan = document.createElement("span");
teamSpan.style.marginRight = "15px";
teamSpan.style.display = "inline-flex";
teamSpan.style.alignItems = "center";
teamSpan.style.gap = "5px";
teamSpan.style.padding = ".2rem .4rem";
teamSpan.style.borderRadius = "0.2rem";
teamSpan.style.border = "solid 1px black";
const img = document.createElement("img");
img.src = team.icon;
img.style.height = "15px";
teamSpan.appendChild(img);
const position = document.createElement("span");
position.textContent = `${team.position}.`;
teamSpan.appendChild(position);
const link = team.nameElement.cloneNode(true);
teamSpan.appendChild(link);
const goalDiffSpan = document.createElement("span");
goalDiffSpan.textContent = `(${team.goalDifference})`;
teamSpan.appendChild(goalDiffSpan);
teamsTd.appendChild(teamSpan);
});
}
}
}
new DateHighlighter().start();
new PlayerProfileDisplayer().start();
new UserRating().start();
new AlternativeTable().start();
})();