Transfermarkt: Forum Utils

Highlight post age, user rating/muting, profile cards, alternative table view

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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();
})();