X Timeline Sync

跟踪并同步您在 Twitter/X 上的最后阅读位置,提供手动和自动选项。完美解决在查看新帖子时不丢失当前位置的问题。

当前为 2025-02-26 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name              X Timeline Sync
// @description       Tracks and syncs your last reading position on Twitter/X, with manual and automatic options. Ideal for keeping track of new posts without losing your place.
// @description:de    Verfolgt und synchronisiert Ihre letzte Leseposition auf Twitter/X, mit manuellen und automatischen Optionen. Perfekt, um neue Beiträge im Blick zu behalten, ohne die aktuelle Position zu verlieren.
// @description:es    Rastrea y sincroniza tu última posición de lectura en Twitter/X, con opciones manuales y automáticas. Ideal para mantener el seguimiento de las publicaciones nuevas sin perder tu posición.
// @description:fr    Suit et synchronise votre dernière position de lecture sur Twitter/X, avec des options manuelles et automatiques. Idéal pour suivre les nouveaux posts sans perdre votre place actuelle.
// @description:zh-CN 跟踪并同步您在 Twitter/X 上的最后阅读位置,提供手动和自动选项。完美解决在查看新帖子时不丢失当前位置的问题。
// @description:ru    Отслеживает и синхронизирует вашу последнюю позицию чтения на Twitter/X с ручными и автоматическими опциями. Идеально подходит для просмотра новых постов без потери текущей позиции.
// @description:ja    Twitter/X での最後の読み取り位置を追跡して同期します。手動および自動オプションを提供します。新しい投稿を見逃さずに現在の位置を維持するのに最適です。
// @description:pt-BR Rastrea e sincroniza sua última posição de leitura no Twitter/X, com opções manuais e automáticas. Perfeito para acompanhar novos posts sem perder sua posição atual.
// @description:hi    Twitter/X पर आपकी अंतिम पठन स्थिति को ट्रैक और सिंक करता है, मैनुअल और स्वचालित विकल्पों के साथ। नई पोस्ट देखते समय अपनी वर्तमान स्थिति को खोए बिना इसे ट्रैक करें।
// @description:ar    يتتبع ويزامن آخر موضع قراءة لك على Twitter/X، مع خيارات يدوية وتلقائية. مثالي لتتبع المشاركات الجديدة دون فقدان موضعك الحالي.
// @description:it    Traccia e sincronizza la tua ultima posizione di lettura su Twitter/X, con opzioni manuali e automatiche. Ideale per tenere traccia dei nuovi post senza perdere la posizione attuale.
// @description:ko    Twitter/X에서 마지막 읽기 위치를 추적하고 동기화합니다. 수동 및 자동 옵션 포함. 새로운 게시물을 확인하면서 현재 위치를 잃지 않도록 이상적입니다.
// @icon              https://x.com/favicon.ico
// @namespace         http://tampermonkey.net/
// @version           2025-02-27
// @author            Copiis
// @license           MIT
// @match             https://x.com/home
// @grant             GM_setValue
// @grant             GM_getValue
// @grant             GM_download
// ==/UserScript==
//                    If you find this script useful and would like to support my work, consider making a small donation!
//                    Bitcoin (BTC): bc1quc5mkudlwwkktzhvzw5u2nruxyepef957p68r7
//                    PayPal: https://www.paypal.com/paypalme/Coopiis?country.x=DE&locale.x=de_DE

(function () {
    let lastReadPost = null;
    let isAutoScrolling = false;
    let isSearching = false;
    let isTabFocused = true;
    let downloadTriggered = false;

    window.onload = async () => {
        if (!window.location.href.includes("/home")) {
            console.log("🚫 Skript deaktiviert: Nicht auf der Home-Seite.");
            return;
        }
        console.log("🚀 Seite vollständig geladen. Initialisiere Skript...");
        await loadNewestLastReadPost();
        await initializeScript();
        createButtons();
    };

    // Füge Tab-Blur-Event hinzu
    window.addEventListener("blur", async () => {
        isTabFocused = false;
        console.log("🌐 Tab nicht mehr fokussiert.");
        if (lastReadPost && !downloadTriggered) {
            downloadTriggered = true;
            console.log("📥 Starte Download der letzten Leseposition...");
            await downloadLastReadPost();
        }
    });

    window.addEventListener("focus", () => {
        isTabFocused = true;
        downloadTriggered = false;
        console.log("🟢 Tab wieder fokussiert.");
    });

    async function initializeScript() {
        console.log("🔧 Lade Leseposition...");
        await loadLastReadPostFromFile();
        observeForNewPosts();

        window.addEventListener("scroll", () => {
            if (isAutoScrolling || isSearching) {
                console.log("⏹️ Scroll-Ereignis ignoriert (automatischer Modus aktiv).");
                return;
            }
            markTopVisiblePost(true);
        });
    }

    async function downloadLastReadPost() {
        if (!lastReadPost || !lastReadPost.timestamp || !lastReadPost.authorHandler) {
            console.warn("⚠️ Keine gültige Leseposition zum Herunterladen.");
            return;
        }

        try {
            const data = JSON.stringify(lastReadPost, null, 2);
            const sanitizedHandler = lastReadPost.authorHandler.replace(/[^a-zA-Z0-9-_]/g, "");
            const timestamp = new Date(lastReadPost.timestamp).toISOString().replace(/[:.-]/g, "_");
            const fileName = `${sanitizedHandler}_${timestamp}.json`;

            GM_download({
                url: `data:application/json;charset=utf-8,${encodeURIComponent(data)}`,
                name: fileName,
                onload: () => console.log(`✅ Leseposition erfolgreich heruntergeladen: ${fileName}`),
                onerror: (err) => console.error("❌ Fehler beim Herunterladen der Leseposition:", err),
            });
        } catch (error) {
            console.error("❌ Fehler beim Herunterladen der Leseposition:", error);
        }
    }

    async function loadNewestLastReadPost() {
        try {
            const localData = GM_getValue("lastReadPost", null);
            if (localData) {
                lastReadPost = JSON.parse(localData);
                console.log("✅ Lokale Leseposition geladen:", lastReadPost);
            } else {
                console.warn("⚠️ Keine gespeicherte Leseposition gefunden.");
            }
        } catch (err) {
            console.error("❌ Fehler beim Laden der neuesten Leseposition:", err);
        }
    }

    async function loadLastReadPostFromFile() {
        try {
            const data = GM_getValue("lastReadPost", null);
            if (data) {
                lastReadPost = JSON.parse(data);
                console.log("✅ Leseposition erfolgreich geladen:", lastReadPost);
            } else {
                console.warn("⚠️ Keine gespeicherte Leseposition gefunden.");
            }
        } catch (err) {
            console.error("❌ Fehler beim Laden der Leseposition:", err);
        }
    }

    function markTopVisiblePost(save = true) {
        const topPost = getTopVisiblePost();
        if (!topPost) {
            console.log("❌ Kein oberster sichtbarer Beitrag gefunden.");
            return;
        }

        const postTimestamp = getPostTimestamp(topPost);
        const authorHandler = getPostAuthorHandler(topPost);

        if (postTimestamp && authorHandler) {
            if (save && (!lastReadPost || new Date(postTimestamp) > new Date(lastReadPost.timestamp))) {
                lastReadPost = { timestamp: postTimestamp, authorHandler };
                console.log("💾 Leseposition aktualisiert:", lastReadPost);
            }
        }
    }

    function getTopVisiblePost() {
        const posts = Array.from(document.querySelectorAll("article"));
        return posts.find(post => {
            const rect = post.getBoundingClientRect();
            return rect.top >= 0 && rect.bottom > 0;
        });
    }

    function getPostTimestamp(post) {
        const timeElement = post.querySelector("time");
        return timeElement ? timeElement.getAttribute("datetime") : null;
    }

    function getPostAuthorHandler(post) {
        const handlerElement = post.querySelector('[role="link"][href*="/"]');
        return handlerElement ? handlerElement.getAttribute("href").slice(1) : null;
    }

// Aufruf im onload
window.onload = async () => {
    if (!window.location.href.includes("/home")) {
        console.log("🚫 Skript deaktiviert: Nicht auf der Home-Seite.");
        return;
    }
    console.log("🚀 Seite vollständig geladen. Initialisiere Skript...");
    await loadNewestLastReadPost(); // Neueste Lesestelle laden
    await initializeScript();
    createButtons();
};

    function startRefinedSearchForLastReadPost() {
    if (!lastReadPost || !lastReadPost.timestamp || !lastReadPost.authorHandler) {
        console.log("❌ Keine gültige Leseposition verfügbar. Suche übersprungen.");
        return;
    }

    console.log("🔍 Verfeinerte Suche gestartet...");
    const popup = createSearchPopup();

    let direction = 1; // 1 = nach unten, -1 = nach oben
    let scrollAmount = 2000; // Anfangsschrittweite
    let previousScrollY = -1;

    function handleSpaceKey(event) {
        if (event.code === "Space") {
            console.log("⏹️ Suche manuell abgebrochen.");
            isSearching = false;
            popup.remove();
            window.removeEventListener("keydown", handleSpaceKey);
        }
    }

    window.addEventListener("keydown", handleSpaceKey);

    const search = () => {
        if (!isSearching) {
            popup.remove();
            return;
        }

        const visiblePosts = getVisiblePosts();
        const comparison = compareVisiblePostsToLastReadPost(visiblePosts);

        if (comparison === "match") {
            const matchedPost = findPostByData(lastReadPost);
            if (matchedPost) {
                console.log("🎯 Beitrag gefunden:", lastReadPost);
                isAutoScrolling = true; // Frühzeitig setzen, um Konflikte zu vermeiden
                scrollToPostWithHighlight(matchedPost);
                isSearching = false;
                popup.remove();
                window.removeEventListener("keydown", handleSpaceKey);
                return;
            }
        } else if (comparison === "older") {
            direction = -1; // Nach oben scrollen
        } else if (comparison === "newer") {
            direction = 1; // Nach unten scrollen
        }

        if (window.scrollY === previousScrollY) {
            scrollAmount = Math.max(scrollAmount / 2, 500); // Schrittweite halbieren bei Stillstand
            direction = -direction; // Richtung umkehren
        } else {
            scrollAmount = Math.min(scrollAmount * 1.5, 3000); // Schrittweite erhöhen
        }

        previousScrollY = window.scrollY;

        window.scrollBy(0, direction * scrollAmount);

        setTimeout(search, 300);
    };

    isSearching = true;
    search();
}

    function createSearchPopup() {
    const popup = document.createElement("div");
    popup.style.position = "fixed";
    popup.style.bottom = "20px";
    popup.style.left = "50%";
    popup.style.transform = "translateX(-50%)";
    popup.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
    popup.style.color = "#ffffff";
    popup.style.padding = "10px 20px";
    popup.style.borderRadius = "8px";
    popup.style.fontSize = "14px";
    popup.style.boxShadow = "0 0 10px rgba(255, 255, 255, 0.8)";
    popup.style.zIndex = "10000";
    popup.textContent = "🔍 Verfeinerte Suche läuft... Drücke SPACE, um abzubrechen.";
    document.body.appendChild(popup);
    return popup;
}

    function compareVisiblePostsToLastReadPost(posts) {
    const validPosts = posts.filter(post => post.timestamp && post.authorHandler);

    if (validPosts.length === 0) {
        console.log("⚠️ Keine sichtbaren Beiträge gefunden.");
        return null;
    }

    const lastReadTime = new Date(lastReadPost.timestamp);

    const allOlder = validPosts.every(post => new Date(post.timestamp) < lastReadTime);
    const allNewer = validPosts.every(post => new Date(post.timestamp) > lastReadTime);

    if (validPosts.some(post => post.timestamp === lastReadPost.timestamp && post.authorHandler === lastReadPost.authorHandler)) {
        return "match";
    } else if (allOlder) {
        return "older";
    } else if (allNewer) {
        return "newer";
    } else {
        return "mixed";
    }
}

    function scrollToPostWithHighlight(post) {
    if (!post) {
        console.log("❌ Kein Beitrag zum Scrollen gefunden.");
        return;
    }

    isAutoScrolling = true;

    // Goldenes Glühen mit Animation vorbereiten
    post.style.outline = "none"; // Kein Outline, nur Glühen
    post.style.boxShadow = "0 0 20px 10px rgba(255, 215, 0, 0.9)"; // Startwert für Glühen
    post.style.animation = "glow 2s infinite"; // Pulsierende Animation

    // CSS-Animation für das Glühen definieren
    if (!document.querySelector('#glowStyle')) {
        const style = document.createElement('style');
        style.id = 'glowStyle';
        style.textContent = `
            @keyframes glow {
                0% { box-shadow: 0 0 20px 10px rgba(255, 215, 0, 0.9); } /* Dickes Glühen */
                50% { box-shadow: 0 0 5px 2px rgba(255, 215, 0, 0.6); } /* Dünnes Glühen */
                100% { box-shadow: 0 0 20px 10px rgba(255, 215, 0, 0.9); } /* Zurück zu dick */
            }
        `;
        document.head.appendChild(style);
    }

    // Post in die Mitte des Bildschirms scrollen
    post.scrollIntoView({ behavior: "smooth", block: "center" });

    // Listener für manuelles Scrollen hinzufügen
    const removeHighlightOnScroll = () => {
        if (!isAutoScrolling) { // Nur bei manuellem Scrollen entfernen
            post.style.boxShadow = "none";
            post.style.animation = "none"; // Animation stoppen
            console.log("✅ Hervorhebung (pulsierendes Glühen) entfernt nach manueller Scroll-Bewegung.");
            window.removeEventListener("scroll", removeHighlightOnScroll); // Listener entfernen
        }
    };

    // Nach dem automatischen Scrollen Auto-Scrolling zurücksetzen und Listener aktivieren
    setTimeout(() => {
        isAutoScrolling = false;
        window.addEventListener("scroll", removeHighlightOnScroll);
        console.log("✅ Beitrag zentriert, wartet auf manuelles Scrollen zum Entfernen des Glühens.");
    }, 1000); // 1 Sekunde Verzögerung, um sicherzustellen, dass der automatische Scroll abgeschlossen ist
}

    function getVisiblePosts() {
    const posts = Array.from(document.querySelectorAll("article"));
    return posts.map(post => ({
        element: post,
        timestamp: getPostTimestamp(post),
        authorHandler: getPostAuthorHandler(post),
    }));
}

async function saveLastReadPostToFile() {
    try {
        if (!lastReadPost || !lastReadPost.timestamp || !lastReadPost.authorHandler) {
            console.warn("⚠️ Keine gültige Leseposition vorhanden. Speichern übersprungen.");
            return;
        }

        // Aktuell gespeicherte Lesestelle abrufen
        const existingData = GM_getValue("lastReadPost", null);
        if (existingData) {
            const existingPost = JSON.parse(existingData);

            // Vergleich: Ist die neue Lesestelle wirklich neuer?
            if (
                existingPost.timestamp === lastReadPost.timestamp &&
                existingPost.authorHandler === lastReadPost.authorHandler
            ) {
                console.log("⏹️ Lesestelle ist identisch mit der gespeicherten. Kein Download erforderlich.");
                return;
            }

            // Falls die neue Lesestelle älter ist, nicht überschreiben
            if (new Date(existingPost.timestamp) > new Date(lastReadPost.timestamp)) {
                console.log("⏹️ Gespeicherte Lesestelle ist neuer. Kein Download erforderlich.");
                return;
            }
        }

        // Speichern der neuen Lesestelle
        GM_setValue("lastReadPost", JSON.stringify(lastReadPost));
        console.log("💾 Leseposition erfolgreich gespeichert:", lastReadPost);

        // Herunterladen der neuen Lesestelle
        await downloadLastReadPost();
    } catch (err) {
        console.error("❌ Fehler beim Speichern der Leseposition:", err);
    }
}

async function deleteOldReadingPositions(handler) {
    console.log(`🗑️ Ältere Lesestellen für den Handler "${handler}" werden simuliert entfernt.`);
    // Tampermonkey kann keine Dateien direkt löschen. Dies ist eine Simulation.
    // In einer echten Implementierung könnte eine serverseitige Lösung verwendet werden.
}

    function observeForNewPosts() {
    const observer = new MutationObserver(() => {
        if (window.scrollY <= 3 && !isSearching) { // Nur prüfen, wenn maximal 3 Pixel vom oberen Rand entfernt und nicht bereits gesucht wird
            const newPostsIndicator = getNewPostsIndicator();

            if (newPostsIndicator) {
                console.log("🆕 Neue Beiträge erkannt. Automatische Suche wird gestartet...");
                clickNewPostsIndicator(newPostsIndicator);
                setTimeout(() => {
                    startRefinedSearchForLastReadPost();
                }, 1500); // Erhöhte Verzögerung auf 1,5 Sekunden
            }
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true,
    });
}

    function getNewPostsIndicator() {
    const elements = document.querySelectorAll('[aria-label]');
    for (const element of elements) {
        const ariaLabel = element.getAttribute('aria-label');
        if (ariaLabel && ariaLabel.includes('undefined')) {
            console.log(`🆕 Neuer Beitrags-Indikator gefunden mit aria-label: "${ariaLabel}"`);
            // Prüfe, ob das Element oder ein übergeordnetes Element ein Button ist
            const button = element.closest('button[role="button"]') || element;
            if (button) {
                return button;
            }
        }
    }
    console.warn("⚠️ Kein neuer Beitragsindikator mit 'undefined' gefunden.");
    return null;
}

    function clickNewPostsIndicator(indicator) {
    if (!indicator) {
        console.warn("⚠️ Kein Indikator für neue Beiträge gefunden.");
        return;
    }

    console.log("✅ Neuer Beitragsindikator wird geklickt...");
    indicator.click();
    console.log("✅ Neuer Beitragsindikator wurde erfolgreich geklickt.");
}

function findPostByData(data) {
    const posts = Array.from(document.querySelectorAll("article"));
    return posts.find(post => {
        const postTimestamp = getPostTimestamp(post);
        const authorHandler = getPostAuthorHandler(post);
        return postTimestamp === data.timestamp && authorHandler === data.authorHandler;
    });
}

function createButtons() {
    const buttonContainer = document.createElement("div");
    buttonContainer.style.position = "fixed";
    buttonContainer.style.top = "50%";
    buttonContainer.style.left = "3px";
    buttonContainer.style.transform = "translateY(-50%)";
    buttonContainer.style.display = "flex";
    buttonContainer.style.flexDirection = "column";
    buttonContainer.style.gap = "3px";
    buttonContainer.style.zIndex = "10000";

    const buttonsConfig = [
        {
            icon: "📂",
            title: "Gespeicherte Leseposition laden",
            onClick: async () => {
                await importLastReadPost();
            },
        },
        {
            icon: "🔍",
            title: "Suche manuell starten",
            onClick: () => {
                console.log("🔍 Manuelle Suche gestartet.");
                startRefinedSearchForLastReadPost();
            },
        },
    ];

    buttonsConfig.forEach(({ icon, title, onClick }) => {
        const button = createButton(icon, title, onClick);
        buttonContainer.appendChild(button);
    });

    document.body.appendChild(buttonContainer);
}

function createButton(icon, title, onClick) {
    const button = document.createElement("div");
    button.style.width = "36px";
    button.style.height = "36px";
    button.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
    button.style.color = "#ffffff";
    button.style.borderRadius = "50%";
    button.style.display = "flex";
    button.style.justifyContent = "center";
    button.style.alignItems = "center";
    button.style.cursor = "pointer";
    button.style.fontSize = "18px";
    button.style.boxShadow = "inset 0 0 10px rgba(255, 255, 255, 0.5)";
    button.style.transition = "transform 0.2s, box-shadow 0.3s";
    button.textContent = icon;
    button.title = title;

    button.addEventListener("click", () => {
        button.style.boxShadow = "inset 0 0 20px rgba(255, 255, 255, 0.8)";
        button.style.transform = "scale(0.9)";
        setTimeout(() => {
            button.style.boxShadow = "inset 0 0 10px rgba(255, 255, 255, 0.5)";
            button.style.transform = "scale(1)";
        }, 300);
        onClick();
    });

    button.addEventListener("mouseenter", () => {
        button.style.boxShadow = "inset 0 0 15px rgba(255, 255, 255, 0.7)";
        button.style.transform = "scale(1.1)";
    });

    button.addEventListener("mouseleave", () => {
        button.style.boxShadow = "inset 0 0 10px rgba(255, 255, 255, 0.5)";
        button.style.transform = "scale(1)";
    });

    return button;
}

async function importLastReadPost() {
    const input = document.createElement("input");
    input.type = "file";
    input.accept = "application/json";
    input.style.display = "none";

    input.addEventListener("change", async (event) => {
        const file = event.target.files[0];
        if (file) {
            const reader = new FileReader();
            reader.onload = async () => {
                try {
                    const importedData = JSON.parse(reader.result);
                    if (importedData.timestamp && importedData.authorHandler) {
                        lastReadPost = importedData;
                        await saveLastReadPostToFile();
                        showPopup("✅ Leseposition erfolgreich geladen. Navigiere zur Leseposition...");
                        console.log("✅ Importierte Leseposition:", lastReadPost);

                        // Navigiere direkt zur Leseposition
                        const matchedPost = findPostByData(lastReadPost);
                        if (matchedPost) {
                            scrollToPostWithHighlight(matchedPost);
                        } else {
                            showPopup("⚠️ Beitrag konnte nicht gefunden werden.");
                            console.warn("⚠️ Kein Beitrag zur geladenen Leseposition gefunden.");
                        }
                    } else {
                        throw new Error("Ungültige Leseposition");
                    }
                } catch (error) {
                    console.error("❌ Fehler beim Importieren der Leseposition:", error);
                    showPopup("❌ Fehler: Ungültige Leseposition.");
                }
            };
            reader.readAsText(file);
        }
    });

    document.body.appendChild(input);
    input.click();
    document.body.removeChild(input);
}

function showPopup(message) {
    const popup = document.createElement("div");
    popup.style.position = "fixed";
    popup.style.bottom = "20px";
    popup.style.right = "20px";
    popup.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
    popup.style.color = "#ffffff";
    popup.style.padding = "10px 20px";
    popup.style.borderRadius = "8px";
    popup.style.fontSize = "14px";
    popup.style.boxShadow = "0 0 10px rgba(255, 255, 255, 0.8)";
    popup.style.zIndex = "10000";
    popup.textContent = message;

    document.body.appendChild(popup);

    setTimeout(() => {
        popup.remove();
    }, 3000);
}

})();