X Timeline Sync

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.

目前為 2025-02-26 提交的版本,檢視 最新版本

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

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址