返回首頁 

Greasy Fork is available in English.

X Timeline Sync

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.

Version au 08/01/2025. Voir la dernière version.


Installer ce script?
// ==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-01-08// @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() {'use strict';let lastReadPost = null;let isAutoScrolling = false;let isSearching = false;let isTabFocused = true;let downloadTriggered = false;let scrollTimeout;// Internationalizationconst translations = {en: {scriptDisabled: "🚫 Script disabled: Not on the home page.",pageLoaded: "🚀 Page fully loaded. Initializing script...",tabBlur: "🌐 Tab lost focus.",downloadStart: "📥 Starting download of last read position...",alreadyDownloaded: "🗂️ Position already downloaded.",tabFocused: "🟢 Tab refocused.",sav###ccess: "✅ Last read position saved:",saveFail: "⚠️ No valid position to save.",noPostFound: "❌ No top visible post found.",highlightSuccess: "✅ Post highlighted successfully.",searchStart: "🔍 Refined search started...",searchCancel: "⏹️ Search manually canceled.",},de: {scriptDisabled: "🚫 Skript deaktiviert: Nicht auf der Home-Seite.",pageLoaded: "🚀 Seite vollständig geladen. Initialisiere Skript...",tabBlur: "🌐 Tab hat den Fokus verloren.",downloadStart: "📥 Starte Download der letzten Leseposition...",alreadyDownloaded: "🗂️ Leseposition bereits im Download-Ordner vorhanden.",tabFocused: "🟢 Tab wieder fokussiert.",sav###ccess: "✅ Leseposition erfolgreich gespeichert:",saveFail: "⚠️ Keine gültige Leseposition zum Speichern.",noPostFound: "❌ Kein oberster sichtbarer Beitrag gefunden.",highlightSuccess: "✅ Beitrag erfolgreich hervorgehoben.",searchStart: "🔍 Verfeinerte Suche gestartet...",searchCancel: "⏹️ Suche manuell abgebrochen.",}};const userLang = navigator.language.split('-')[0];const t = (key) => translations[userLang]?.[key] || translations.en[key];function loadNewestLastReadPost() {const data = GM_getValue("lastReadPost", null);if (data) {lastReadPost = JSON.parse(data);console.log(t("sav###ccess"), lastReadPost);} else {console.warn(t("saveFail"));}}function loadLastReadPostFromFile() {loadNewestLastReadPost();}function saveLastReadPostToFile() {if (lastReadPost && lastReadPost.timestamp && lastReadPost.authorHandler) {GM_setValue("lastReadPost", JSON.stringify(lastReadPost));console.log(t("sav###ccess"), lastReadPost);} else {console.warn(t("saveFail"));}}function downloadLastReadPost() {if (!lastReadPost || !lastReadPost.timestamp || !lastReadPost.authorHandler) {console.warn(t("saveFail"));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(`${t("sav###ccess")} ${fileName}`),onerror: (err) => console.error("❌ Error downloading:", err),});} catch (error) {console.error("❌ Download error:", error);}}function markTopVisiblePost(save = true) {const topPost = getTopVisiblePost();if (!topPost) {console.log(t("noPostFound"));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 };saveLastReadPostToFile();}}}function getTopVisiblePost() {return Array.from(document.querySelectorAll("article")).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;}function startRefinedSearchForLastReadPost() {if (!lastReadPost || !lastReadPost.timestamp || !lastReadPost.authorHandler) return;console.log(t("searchStart"));const popup = createSearchPopup();let direction = 1; // 1 for down, -1 for uplet scrollAmount = 2000;let previousScrollY = -1;function handleSpaceKey(event) {if (event.code === "Space") {console.log(t("searchCancel"));isSearching = false;popup.remove();window.removeEventListener("keydown", handleSpaceKey);}}window.addEventListener("keydown", handleSpaceKey);function search() {if (!isSearching) {popup.remove();return;}const comparison = compareVisiblePostsToLastReadPost(getVisiblePosts());if (comparison === "match") {const matchedPost = findPostByData(lastReadPost);if (matchedPost) {scrollToPostWithHighlight(matchedPost);isSearching = false;popup.remove();window.removeEventListener("keydown", handleSpaceKey);return;}} else if (comparison === "older") {direction = -1;} else if (comparison === "newer") {direction = 1;}if (window.scrollY === previousScrollY) {scrollAmount = Math.max(scrollAmount / 2, 500);direction = -direction;} else {scrollAmount = Math.min(scrollAmount * 1.5, 3000);}previousScrollY = window.scrollY;window.scrollBy(0, direction * scrollAmount);setTimeout(search, 300);}isSearching = true;search();}function createSearchPopup() {const popup = document.createElement("div");popup.style.cssText = `position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.9); color: #fff; padding: 10px 20px; border-radius: 8px; font-size: 14px; box-shadow: 0 0 10px rgba(255, 255, 255, 0.8); z-index: 10000;`;popup.textContent = "🔍 Refined search in progress... Press SPACE to cancel.";document.body.appendChild(popup);return popup;}function compareVisiblePostsToLastReadPost(posts) {const validPosts = posts.filter(post => post.timestamp && post.authorHandler);if (validPosts.length === 0) return null;const lastReadTime = new Date(lastReadPost.timestamp);if (validPosts.some(post => post.timestamp === lastReadPost.timestamp && post.authorHandler === lastReadPost.authorHandler)) {return "match";} else if (validPosts.every(post => new Date(post.timestamp) < lastReadTime)) {return "older";} else if (validPosts.every(post => new Date(post.timestamp) > lastReadTime)) {return "newer";} else {return "mixed";}}function scrollToPostWithHighlight(post) {if (!post) return;isAutoScrolling = true;post.style.cssText = `outline: none; box-shadow: 0 0 15px 5px rgba(255, 223, 0, 0.9); transition: box-shadow 1.5s ease-in-out, transform 0.3s ease; border-radius: 12px; transform: scale(1.02);`;post.scrollIntoView({ behavior: "smooth", block: "center" });setTimeout(() => {post.style.boxShadow = "0 0 0 0 rgba(255, 223, 0, 0)";post.style.transform = "scale(1)";}, 2000);setTimeout(() => {post.style.boxShadow = "none";post.style.borderRadius = "unset";isAutoScrolling = false;console.log(t("highlightSuccess"));}, 4500);}function getVisiblePosts() {return Array.from(document.querySelectorAll("article")).map(post => ({element: post,timestamp: getPostTimestamp(post),authorHandler: getPostAuthorHandler(post)}));}function findPostByData(data) {return Array.from(document.querySelectorAll("article")).find(post => {const postTimestamp = getPostTimestamp(post);const authorHandler = getPostAuthorHandler(post);return postTimestamp === data.timestamp && authorHandler === data.authorHandler;});}function createButtons() {const container = document.createElement("div");container.style.cssText = `position: fixed; top: 50%; left: 3px; transform: translateY(-50%); display: flex; flex-direction: column; gap: 3px; z-index: 10000;`;const buttons = [{ icon: "📂", title: "Load saved reading position", onClick: importLastReadPost },{ icon: "🔍", title: "Start manual search", onClick: startRefinedSearchForLastReadPost }];buttons.forEach(({ icon, title, onClick }) => {const button = document.createElement("div");button.style.cssText = `width: 36px; height: 36px; background: rgba(0, 0, 0, 0.9); color: #fff; border-radius: 50%; display: flex; justify-content: center; align-items: center; cursor: pointer; font-size: 18px; box-shadow: inset 0 0 10px rgba(255, 255, 255, 0.5); transition: all 0.2s ease;`;button.title = title;button.textContent = icon;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)";onClick();}, 300);});["mouseenter", "mouseleave"].forEach(event =>button.addEventListener(event, () => button.style.transform = event === "mouseenter" ? "scale(1.1)" : "scale(1)"));container.appendChild(button);});document.body.appendChild(container);}function importLastReadPost() {const input = document.createElement("input");input.type = "file";input.accept = "application/json";input.style.display = "none";input.addEventListener("change", (event) => {const file = event.target.files[0];if (file) {const reader = new FileReader();reader.onload = () => {try {const importedData = JSON.parse(reader.r###lt);if (importedData.timestamp && importedData.authorHandler) {lastReadPost = importedData;saveLastReadPostToFile();startRefinedSearchForLastReadPost();} else {throw new Error("Invalid reading position");}} catch (error) {console.error("❌ Error importing reading position:", error);}};reader.readAsText(file);}});document.body.appendChild(input);input.click();document.body.removeChild(input);}function observeForNewPosts() {const targetNode = document.querySelector('div[aria-label="Timeline: Your Home Timeline"]') || document.body;// MutationObserver for content changesconst mutationObserver = new MutationObserver(mutations => {for (const mutation of mutations) {if (mutation.type === 'childList') {checkForNewPosts();}}});mutationObserver.observe(targetNode, { childList: true, subtree: true });// IntersectionObserver for detecting when elements enter the viewportconst intersectionObserver = new IntersectionObserver(entries => {entries.forEach(entry => {if (entry.isIntersecting) {checkForNewPosts();}});}, { rootMargin: '0px', threshold: 0.1 });// Observe each post as it's added to the DOMconst observeNewPosts = () => {document.querySelectorAll('article').forEach(post => {intersectionObserver.observe(post);});};// Initial setupobserveNewPosts();// Re-observe whenever the DOM changes due to new postsconst checkForNewPosts = () => {const newPostsIndicator = getNewPostsIndicator();if (newPostsIndicator) {console.log("🆕 New posts detected. Starting automatic search...");clickNewPostsIndicator(newPostsIndicator);setTimeout(() => startRefinedSearchForLastReadPost(), 1000);}// Re-observe all posts to catch any newly added onesobserveNewPosts();};}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())) {return button;}}}return null;}function clickNewPostsIndicator(indicator) {if (!indicator) return;indicator.scrollIntoView({ behavior: "smooth", block: "center" });setTimeout(() => indicator.click(), 500);}window.onload = async () => {if (!window.location.href.includes("/home")) {console.log(t("scriptDisabled"));return;}console.log(t("pageLoaded"));await loadNewestLastReadPost();await initializeScript();createButtons();};async function initializeScript() {console.log(t("pageLoaded"));await loadLastReadPostFromFile();observeForNewPosts();window.addEventListener("scroll", () => {clearTimeout(scrollTimeout);scrollTimeout = setTimeout(() => {if (!isAutoScrolling && !isSearching) {markTopVisiblePost(true);}}, 100); // 100ms Debounce});}window.addEventListener("blur", async () => {console.log(t("tabBlur"));if (lastReadPost && !downloadTriggered) {downloadTriggered = true;if (!(await isFileAlreadyDownloaded())) {console.log(t("downloadStart"));await downloadLastReadPost();await markDownloadAsComplete();} else {console.log(t("alreadyDownloaded"));}downloadTriggered = false;}});window.addEventListener("focus", () => {isTabFocused = true;downloadTriggered = false;console.log(t("tabFocused"));});async function isFileAlreadyDownloaded() {const localFiles = await GM_getValue("downloadedPosts", []);const fileSignature = `${lastReadPost.authorHandler}_${lastReadPost.timestamp}`;return localFiles.includes(fileSignature);}async function markDownloadAsComplete() {const localFiles = await GM_getValue("downloadedPosts", []);const fileSignature = `${lastReadPost.authorHandler}_${lastReadPost.timestamp}`;if (!localFiles.includes(fileSignature)) {localFiles.push(fileSignature);GM_setValue("downloadedPosts", localFiles);}}})();