Transfermarkt: Forum Utils

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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