X Timeline Sync

Track and sync your last reading position on Twitter/X. Automatic and manual options for smooth navigation of new posts.

目前為 2024-12-19 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name              X Timeline Sync
// @description       Track and sync your last reading position on Twitter/X. Automatic and manual options for smooth navigation of new posts.
// @description:de    Verfolgen und synchronisieren Sie Ihre letzte Leseposition auf Twitter/X. Automatische und manuelle Optionen für eine reibungslose Navigation durch neue Beiträge.
// @description:es    Rastrea y sincroniza tu última posición de lectura en Twitter/X. Opciones automáticas y manuales para navegar fácilmente por nuevas publicaciones.
// @description:fr    Suivez et synchronisez votre dernière position de lecture sur Twitter/X. Options automatiques et manuelles pour naviguer facilement parmi les nouveaux posts.
// @description:zh-CN 跟踪并同步您在 Twitter/X 上的最后阅读位置。提供自动和手动选项以便轻松浏览新帖子。
// @description:ru    Отслеживайте и синхронизируйте вашу последнюю позицию чтения на Twitter/X. Автоматические и ручные параметры для удобной навигации по новым постам.
// @description:ja    Twitter/Xでの最後の読み取り位置を追跡して同期します。新しい投稿をスムーズにナビゲートするための自動および手動オプション。
// @description:pt-BR Rastreie e sincronize sua última posição de leitura no Twitter/X. Opções automáticas e manuais para navegação suave em novas postagens.
// @description:hi    Twitter/X पर अपनी अंतिम पढ़ने की स्थिति को ट्रैक और सिंक करें। नए पोस्ट पर आसान नेविगेशन के लिए स्वचालित और मैन्युअल विकल्प।
// @description:ar    تتبع وقم بمزامنة آخر موضع قراءة لك على Twitter/X. خيارات تلقائية ويدوية لتصفح سلس للمنشورات الجديدة.
// @description:it    Traccia e sincronizza la tua ultima posizione di lettura su Twitter/X. Opzioni automatiche e manuali per una navigazione fluida dei nuovi post.
// @description:ko    Twitter/X에서 마지막 읽기 위치를 추적하고 동기화하세요. 새로운 게시물을 원활하게 탐색하기 위한 자동 및 수동 옵션을 제공합니다。
// @icon              https://x.com/favicon.ico
// @namespace         http://tampermonkey.net/
// @version           2024.12.20
// @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; // Letzte Leseposition
    let isAutoScrolling = false;
    let isSearching = 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(); // Neueste Lesestelle laden
    await initializeScript();
    createButtons();
};

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

        observeForNewPosts();

        // Scroll-Listener für manuelles Scrollen hinzufügen
        window.addEventListener("scroll", () => {
            if (isAutoScrolling || isSearching) {
                console.log("⏹️ Scroll-Ereignis ignoriert (automatischer Modus aktiv).");
                return;
            }
            // Obersten sichtbaren Beitrag markieren
            markTopVisiblePost(true);
        });
    }
async function loadNewestLastReadPost() {
    try {
        const localData = GM_getValue("lastReadPost", null);
        let localLastReadPost = null;
        if (localData) {
            localLastReadPost = JSON.parse(localData);
        }

        console.log("📂 Lade vorhandene Lesestelle aus dem Download-Ordner...");
        const downloadedData = await loadLastReadPostFromDownloadFolder();
        let downloadedLastReadPost = null;
        if (downloadedData) {
            downloadedLastReadPost = JSON.parse(downloadedData);
        }

        if (!localLastReadPost && !downloadedLastReadPost) {
            console.warn("⚠️ Keine gespeicherten Lesestellen gefunden.");
            return;
        }

        if (
            !localLastReadPost ||
            (downloadedLastReadPost &&
                new Date(downloadedLastReadPost.timestamp) > new Date(localLastReadPost.timestamp))
        ) {
            lastReadPost = downloadedLastReadPost;
            console.log("✅ Neueste Leseposition aus dem Download-Ordner geladen:", lastReadPost);
        } else {
            lastReadPost = localLastReadPost;
            console.log("✅ Neueste Leseposition lokal geladen:", lastReadPost);
        }
    } catch (err) {
        console.error("❌ Fehler beim Laden der neuesten Lesestelle:", err);
    }
}

async function loadLastReadPostFromDownloadFolder() {
    // Simulation des Ladens einer Datei (ersetzt durch API-Aufrufe oder Datei-Parsing-Logik)
    try {
        const exampleData = GM_getValue("lastReadPost", null);
        return exampleData;
    } catch (err) {
        console.error("❌ Fehler beim Zugriff auf den Download-Ordner:", err);
        return 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();
};

    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.");
                lastReadPost = null;
            }
        } catch (err) {
            console.error("⚠️ Fehler beim Laden der Leseposition:", err);
            lastReadPost = null;
        }
    }

    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);
                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;

    post.style.outline = "3px solid rgba(255, 255, 0, 0.8)";
    post.style.transition = "outline 1.5s ease-in-out";

    post.scrollIntoView({ behavior: "smooth", block: "center" });

    setTimeout(() => {
        post.style.outline = "3px solid rgba(255, 255, 0, 0)";
    }, 2000);

    setTimeout(() => {
        post.style.outline = "none";
        isAutoScrolling = false;
        console.log("✅ Beitrag erfolgreich zentriert und Hervorhebung entfernt!");
    }, 4500);
}

    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 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, ""); // Sonderzeichen entfernen
            const timestamp = new Date(lastReadPost.timestamp).toISOString().replace(/[:.-]/g, "_");
            const fileName = `${sanitizedHandler}_${timestamp}.json`;

            // Lösche ältere Lesestellen aus dem Download-Ordner
            await deleteOldReadingPositions(sanitizedHandler);

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

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.
}

    async function loadLastReadPostFromDownloadFolder() {
        // Simulation des Ladens einer Datei (ersetzt durch API-Aufrufe oder Datei-Parsing-Logik)
        console.log("📂 Lade vorhandene Lesestelle aus dem Download-Ordner...");
        // Beispiel-Daten wiederherstellen
        const exampleData = GM_getValue("lastReadPost", null);
        if (exampleData) {
            lastReadPost = JSON.parse(exampleData);
            console.log("✅ Lesestelle aus Download-Ordner geladen:", lastReadPost);
        }
    }

    function observeForNewPosts() {
        const observer = new MutationObserver(() => {
            if (window.scrollY <= 50) {
                const newPostsIndicator = getNewPostsIndicator();

                if (newPostsIndicator) {
                    console.log("🆕 Neue Beiträge erkannt. Automatische Suche wird gestartet...");
                    clickNewPostsIndicator(newPostsIndicator);
                    setTimeout(() => {
                        startRefinedSearchForLastReadPost();
                    }, 1000);
                }
            }
        });

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

    function getNewPostsIndicator() {
        const buttons = document.querySelectorAll('button[role="button"]');

        for (const button of buttons) {
            const innerDiv = button.querySelector('div[style*="text-overflow: unset;"]');
            if (innerDiv) {
                const span = innerDiv.querySelector('span');
                if (span && /^\d+\s/.test(span.textContent.trim())) {
                    console.log(`🆕 Neuer Beitrags-Indikator gefunden: "${span.textContent.trim()}"`);
                    return button;
                }
            }
        }
        console.warn("⚠️ Kein neuer Beitragsindikator 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.scrollIntoView({ behavior: "smooth", block: "center" });
        setTimeout(() => {
            indicator.click();
            console.log("✅ Neuer Beitragsindikator wurde erfolgreich geklickt.");
        }, 500);
    }

    function markTopVisiblePost(save = true) {
        if (isAutoScrolling || isSearching) {
            console.log("⏹️ Automatische Aktionen oder Suche aktiv, Markierung übersprungen.");
            return;
        }

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

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

        if (!postTimestamp || !authorHandler) {
            console.log("❌ Oberster sichtbarer Beitrag hat keine gültigen Daten.");
            return;
        }

        if (save && !isAutoScrolling && !isSearching) {
            if (!lastReadPost || new Date(postTimestamp) > new Date(lastReadPost.timestamp)) {
                lastReadPost = { timestamp: postTimestamp, authorHandler };
                console.log(`💾 Lesestelle aktualisiert: ${postTimestamp}, @${authorHandler}`);
                saveLastReadPostToFile();
            } else {
                console.log("⏹️ Lesestelle nicht aktualisiert, da keine neueren Beiträge gefunden wurden.");
            }
        }
    }

    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*="/"]');
        if (handlerElement) {
            const handler = handlerElement.getAttribute("href");
            return handler && handler.startsWith("/") ? handler.slice(1) : null;
        }
        return null;
    }

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 = "0 0 10px rgba(255, 255, 255, 0.8)";
    button.style.transition = "transform 0.2s";
    button.textContent = icon;
    button.title = title;

    button.addEventListener("click", onClick);
    button.addEventListener("mouseenter", () => {
        button.style.transform = "scale(1.1)";
    });
    button.addEventListener("mouseleave", () => {
        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);
}

})();