// ==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 2024.12.14-1
// @author Copiis
// @license MIT
// @match https://x.com/home
// @grant GM_setValue
// @grant GM_getValue
// ==/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 initializeScript();
createButtons();
};
async function initializeScript() {
console.log("🔧 Lade Leseposition...");
await loadLastReadPostFromFile();
observeForNewPosts(); // Beobachtung für neue Beiträge aktivieren
// 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 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;
}
}
async function saveLastReadPostToFile() {
try {
if (!lastReadPost || !lastReadPost.timestamp || !lastReadPost.authorHandler) {
console.warn("⚠️ Keine gültige Leseposition vorhanden. Speichern übersprungen.");
return;
}
GM_setValue("lastReadPost", JSON.stringify(lastReadPost));
console.log("💾 Leseposition erfolgreich gespeichert:", lastReadPost);
} catch (err) {
console.error("❌ Fehler beim Speichern der Leseposition:", err);
}
}
async function exportLastReadPost() {
if (!lastReadPost || !lastReadPost.timestamp || !lastReadPost.authorHandler) {
console.warn("⚠️ Keine gültige Leseposition zum Exportieren.");
showPopup("⚠️ Keine gültige Leseposition verfügbar.");
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`;
const blob = new Blob([data], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = fileName;
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
showPopup(`✅ Datei "${fileName}" wurde erfolgreich generiert und heruntergeladen.`);
} catch (error) {
console.error("❌ Fehler beim Exportieren der Leseposition:", error);
showPopup("❌ Fehler: Leseposition konnte nicht exportiert werden.");
}
}
function createButtons() {
const buttonContainer = document.createElement("div");
buttonContainer.style.position = "fixed";
buttonContainer.style.top = "10px";
buttonContainer.style.right = "10px"; // Positionierung auf der rechten Seite
buttonContainer.style.display = "flex";
buttonContainer.style.flexDirection = "column"; // Anordnung untereinander
buttonContainer.style.gap = "10px"; // Abstand zwischen den Buttons
buttonContainer.style.zIndex = "10000";
const buttonsConfig = [
{ icon: "💾", title: "Leseposition exportieren", onClick: exportLastReadPost },
{ icon: "📂", title: "Gespeicherte Leseposition importieren", onClick: importLastReadPost },
{
icon: "🔍",
title: "Suche manuell starten",
onClick: () => {
console.log("🔍 Manuelle Suche gestartet.");
startSearchForLastReadPost();
},
},
];
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 = "24px"; // Reduced width
button.style.height = "24px"; // Reduced height
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 = "12px"; // Smaller font size
button.style.boxShadow = "0 0 5px rgba(255, 255, 255, 0.8)";
button.textContent = icon;
button.title = title;
button.addEventListener("click", onClick);
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 importiert.");
console.log("✅ Importierte Leseposition:", lastReadPost);
await startRefinedSearchForLastReadPost();
} 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 observeForNewPosts() {
const observer = new MutationObserver(() => {
if (window.scrollY <= 240) { // Wenn der Nutzer am oberen Rand der Seite ist
const newPostsIndicator = getNewPostsIndicator();
if (newPostsIndicator) {
console.log("🆕 Neue Beiträge erkannt. Automatische Suche wird gestartet...");
clickNewPostsIndicator(newPostsIndicator);
setTimeout(() => {
startRefinedSearchForLastReadPost(); // Automatische Suche nach der Leseposition
}, 1000); // 1 Sekunde warten, damit neue Beiträge vollständig geladen sind
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
function getNewPostsIndicator() {
// Suche nach allen Buttons mit style "text-overflow: unset"
const buttons = document.querySelectorAll('button[role="button"]');
for (const button of buttons) {
const innerDiv = button.querySelector('div[style*="text-overflow: unset;"]'); // Überprüfen des inneren Divs
if (innerDiv) {
const span = innerDiv.querySelector('span');
if (span && /^\d+\s/.test(span.textContent.trim())) { // Prüfen, ob Text mit einer Zahl beginnt
console.log(`🆕 Neuer Beitrags-Indikator gefunden: "${span.textContent.trim()}"`);
return button; // Button zurückgeben
}
}
}
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" }); // Scroll zum Button
setTimeout(() => {
indicator.click(); // Klick ausführen
console.log("✅ Neuer Beitragsindikator wurde erfolgreich geklickt.");
}, 500);
}
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); // Visuelle Hervorhebung
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 {
// Dynamische Anpassung der Schrittweite
scrollAmount = Math.min(scrollAmount * 1.5, 3000); // Schrittweite vergrößern, maximal 3000px
}
previousScrollY = window.scrollY;
// Scrollen
window.scrollBy(0, direction * scrollAmount);
// Nächster Suchdurchlauf
setTimeout(search, 300);
};
isSearching = true;
search();
}
function getVisiblePosts() {
const posts = Array.from(document.querySelectorAll("article"));
return posts.map(post => ({
element: post,
timestamp: getPostTimestamp(post),
authorHandler: getPostAuthorHandler(post),
}));
}
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;
// Visuelle Hervorhebung hinzufügen
post.style.outline = "3px solid rgba(255, 255, 0, 0.8)";
post.style.transition = "outline 0.3s ease-in-out";
// Scrollen und Hervorhebung animieren
post.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => {
post.style.outline = "none"; // Nach kurzer Zeit Hervorhebung entfernen
isAutoScrolling = false;
console.log("✅ Beitrag erfolgreich zentriert und hervorgehoben!");
}, 1500); // Nach 1,5 Sekunden Hervorhebung entfernen
}
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 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 getTopVisiblePost() {
const posts = Array.from(document.querySelectorAll("article")); // Alle Beiträge sammeln
return posts.find(post => {
const rect = post.getBoundingClientRect();
return rect.top >= 0 && rect.bottom > 0; // Oberster sichtbarer Beitrag
});
}
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;
}
// Nur speichern, wenn explizit manuell gescrollt wurde
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 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 scrollToPost(post) {
if (!post) {
console.log("❌ Kein Beitrag zum Scrollen gefunden.");
return;
}
isAutoScrolling = true;
post.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => {
isAutoScrolling = false;
console.log("✅ Beitrag wurde erfolgreich zentriert!");
}, 1000);
}
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);
}
})();