Kleinanzeigen-Datenexport (TO LLM) 🚀

Erfasst sichtbare Inhalte und Metadaten als JSONL für LLM-Verarbeitung. Unterstützt kontinuierliches Sammeln über mehrere Seiten (persistenter Modus).

当前为 2025-06-12 提交的版本,查看 最新版本

// ==UserScript==
// @name         Kleinanzeigen-Datenexport (TO LLM) 🚀
// @namespace    http://tampermonkey.net/
// @version      19.0
// @description  Erfasst sichtbare Inhalte und Metadaten als JSONL für LLM-Verarbeitung. Unterstützt kontinuierliches Sammeln über mehrere Seiten (persistenter Modus).
// @author       Assistant & User
// @license MIT
// @match        https://www.kleinanzeigen.de/*
// @icon         https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=http://kleinanzeigen.de&size=64
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(function() {
    'use strict';

    // Globale Variablen
    let pickerButton = null;
    let longPressTimer = null;
    const LONG_PRESS_THRESHOLD = 500; // ms, Wie lange der Klick gehalten werden muss für Langklick
    let isLockedMode = false; // true wenn im kontinuierlichen Sammel-Modus
    let allCollectedData = []; // Speichert JSONL-Objekte aus allen gesammelten Seiten
    let scriptErrors = []; // Speichert Skript-interne Fehler
    let countdownInterval = null; // Für den Countdown-Timer
    let lastKnownUrl = window.location.href; // Für die Erkennung von Seitenwechseln
    let mutationObserver = null; // Für DOM-Änderungen in SPAs
    const defaultStylesCache = new Map(); // Cache für Standard-CSS-Eigenschaften
    let processingPage = false; // Flag, um mehrfache Verarbeitung bei schnellen DOM-Änderungen zu verhindern

    // Schlüssel für die Speicherung des Modus-Status
    const STORAGE_KEY_LOCKED_MODE = 'kleinanzeigen_picker_locked_mode';

    // Startzeit für Skript-Laufzeitmessung (für Metadaten)
    const scriptStartTime = performance.now();

    // CSS-Stile für den Button und Benachrichtigungen
    GM_addStyle(`
        #element-picker-btn {
            position: fixed; right: 20px; top: 50%; transform: translateY(-50%);
            width: 100px; /* Länglicher */ height: 50px; /* Länglicher */
            background: #007185; border: none; border-radius: 8px; /* Leicht abgerundet */
            cursor: pointer; z-index: 9999; box-shadow: 0 2px 10px rgba(0,0,0,0.3);
            display: flex; align-items: center; justify-content: center;
            transition: background-color 0.3s ease, box-shadow 0.3s ease, opacity 0.3s ease;
            color: white; font-size: 16px; font-weight: bold; text-align: center;
            line-height: 1.2;
            padding: 5px;
        }
        #element-picker-btn:hover { background-color: #005a6b; opacity: 0.9; }
        #element-picker-btn.active { background: #dc3545; animation: pulse 1s infinite; }
        #element-picker-btn.locked { background: #17a2b8; animation: pulse-locked 1s infinite; }
        #element-picker-btn.processing { opacity: 0.6; cursor: wait; } /* Visueller Hinweis während der Datenverarbeitung */

        @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7); } 70% { box-shadow: 0 0 0 10px rgba(220, 53, 69, 0); } 100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); } }
        @keyframes pulse-locked { 0% { box-shadow: 0 0 0 0 rgba(23, 162, 184, 0.7); } 70% { box-shadow: 0 0 0 10px rgba(23, 162, 184, 0); } 100% { box-shadow: 0 0 0 0 rgba(23, 162, 184, 0); } }

        .picker-notification {
            position: fixed; top: 20px; right: 20px; background: #28a745; color: white;
            padding: 10px 20px; border-radius: 5px; z-index: 10000; font-family: Arial, sans-serif;
            font-size: 14px; animation: slideIn 0.3s ease-out; box-shadow: 0 2px 8px rgba(0,0,0,0.2);
        }
        @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }

        .picker-status {
            position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: #343a40; color: white;
            padding: 8px 16px; border-radius: 5px; z-index: 10000; font-family: Arial, sans-serif;
            font-size: 12px; font-weight: bold; box-shadow: 0 2px 8px rgba(0,0,0,0.2);
        }
    `);

    // --- FEHLERPROTOKOLLIERUNG ---
    function logScriptError(error, context) {
        scriptErrors.push({
            timestamp: new Date().toISOString(),
            context: context,
            message: error.message,
            stack: error.stack ? error.stack.split('\n') : 'No stack trace available'
        });
        console.error(`Kleinanzeigen-Skript Fehler [${context}]:`, error);
    }

    // --- HILFSFUNKTIONEN FÜR DATENEXTRAKTION ---
    function parseLocation(locationString) {
        try {
            if (!locationString) return { plz: null, stadt: null, entfernung_km: null };
            const cleanString = locationString.replace(/\s+/g, ' ').trim();
            const plzMatch = cleanString.match(/^(\d{5})/);
            const cityMatch = cleanString.match(/^\d{5}\s(.+?)(?:\s\(|$)/); // Verbessert, um Städte ohne Entfernungsangabe zu erfassen
            const distanceMatch = cleanString.match(/\((\d+)\s*km\)/);
            return {
                plz: plzMatch ? plzMatch[1] : null,
                stadt: cityMatch ? cityMatch[1].trim() : cleanString.replace(/^\d{5}\s*/, '').trim(),
                entfernung_km: distanceMatch ? parseInt(distanceMatch[1], 10) : null
            };
        } catch (e) {
            logScriptError(e, 'parseLocation');
            return { plz: null, stadt: null, entfernung_km: null };
        }
    }

    function parsePrice(priceString) {
        try {
            if (!priceString) return { betrag: null, zusatz: null };
            const cleanString = priceString.replace(/\s+/g, ' ').trim();
            const betragMatch = cleanString.match(/(\d[\d\.]*)/);
            let betrag = null;
            if (betragMatch) {
                betrag = parseFloat(betragMatch[1].replace(/\./g, '').replace(/,/g, '.')); // Auch Kommas für Dezimalzahlen beachten
            }
            const zusatzMatch = cleanString.match(/VB/i);
            return { betrag: betrag, zusatz: zusatzMatch ? 'VB' : null };
        } catch (e) {
            logScriptError(e, 'parsePrice');
            return { betrag: null, zusatz: null };
        }
    }

    function getCompressedCss(element) {
        try {
            const tagName = element.tagName;
            if (!defaultStylesCache.has(tagName)) {
                const dummy = document.createElement(tagName);
                document.body.appendChild(dummy);
                defaultStylesCache.set(tagName, window.getComputedStyle(dummy));
                document.body.removeChild(dummy);
            }
            const styles = window.getComputedStyle(element);
            const defaultStyles = defaultStylesCache.get(tagName);
            const cssProps = {};
            const relevantProperties = [
                'display', 'position', 'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap',
                'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
                'margin', 'padding', 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-radius',
                'box-sizing', 'font-family', 'font-size', 'font-weight', 'font-style', 'color', 'background-color',
                'text-align', 'line-height', 'text-decoration', 'text-transform', 'letter-spacing', 'word-spacing',
                'box-shadow', 'opacity', 'visibility', 'overflow', 'overflow-x', 'overflow-y', 'z-index', 'cursor',
                'pointer-events', 'white-space', 'word-break', 'float', 'clear', 'top', 'bottom', 'left', 'right',
                'background-image', 'background-size', 'background-position', 'background-repeat', 'background-attachment',
                'transform', 'transform-origin', 'transition', 'animation', 'outline', 'outline-offset', 'filter',
                'clip-path', 'mask', 'content', 'vertical-align', 'writing-mode', 'direction',
                'grid-template-columns', 'grid-template-rows', 'grid-gap', 'grid-column', 'grid-row'
            ];

            for (const prop of relevantProperties) {
                const value = styles.getPropertyValue(prop);
                // Nur Werte hinzufügen, die sich vom Standard unterscheiden oder nicht "auto"/"normal" sind (falls relevanter)
                if (value && value !== defaultStyles.getPropertyValue(prop) && value !== 'auto' && value !== 'normal' && value !== '0px') {
                    cssProps[prop] = value;
                }
            }
            // Spezielle Handhabung für 'border' um Redundanz zu vermeiden
            if (cssProps.border && cssProps.border.includes('none')) {
                delete cssProps.border;
                delete cssProps['border-top'];
                delete cssProps['border-right'];
                delete cssProps['border-bottom'];
                delete cssProps['border-left'];
            }
            return Object.keys(cssProps).length > 0 ? cssProps : undefined;
        } catch (e) {
            logScriptError(e, 'getCompressedCss');
            return undefined;
        }
    }

    function getElementGeometry(element) {
        try {
            const rect = element.getBoundingClientRect();
            return {
                x: rect.x,
                y: rect.y,
                width: rect.width,
                height: rect.height,
                inViewport: (
                    rect.top >= 0 &&
                    rect.left >= 0 &&
                    rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
                    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
                )
            };
        } catch (e) {
            logScriptError(e, 'getElementGeometry');
            return undefined;
        }
    }

    function compactHTML(html) {
        try {
            return html.replace(/\s+/g, ' ')
                       .replace(/>\s*</g, '><')
                       .trim();
        } catch (e) {
            logScriptError(e, 'compactHTML');
            return html;
        }
    }

    function buildRecursiveElementTree(element, level = 0, maxDepth = 3) {
        if (level > maxDepth) return [];
        let tree = [];
        for (const child of element.children) {
            if (child.tagName === 'SCRIPT' || child.tagName === 'NOSCRIPT' || child.tagName === 'STYLE' || child.tagName === 'META') continue;

            const childData = {
                tagName: child.tagName.toLowerCase(),
                classes: Array.from(child.classList).filter(cls => !cls.startsWith('picker-')).join(' ') || undefined,
                id: child.id || undefined,
                textContentSnippet: (child.textContent || '').trim().substring(0, 100) + ((child.textContent || '').trim().length > 100 ? '...' : '') || undefined,
            };

            if (child.hasAttribute('href')) {
                childData.href = child.getAttribute('href');
            }
            if (child.hasAttribute('src')) {
                childData.src = child.getAttribute('src');
            }
            if (child.hasAttribute('alt')) {
                childData.alt = child.getAttribute('alt');
            }
            if (child.hasAttribute('data-adid')) {
                childData.dataAttributes = { adid: child.getAttribute('data-adid') };
            }

            if (child.children.length > 0 && level + 1 <= maxDepth) {
                childData.children = buildRecursiveElementTree(child, level + 1, maxDepth);
            }
            tree.push(childData);
        }
        return tree.length > 0 ? tree : undefined;
    }

    function extractElementDetails(element, isAdItem = false) {
        try {
            const tagName = element.tagName.toLowerCase();
            const classes = Array.from(element.classList).filter(cls => !cls.startsWith('picker-')).join(' ') || undefined;
            const textContent = element.textContent ? element.textContent.trim().replace(/\s+/g, ' ') : undefined;
            const domPath = getDomPath(element);
            const geometry = getElementGeometry(element);
            const css = getCompressedCss(element);

            const data = {
                tagName: tagName,
                domPath: domPath,
                id: element.id || undefined,
                classes: classes || undefined,
                dataAttributes: Array.from(element.attributes)
                    .filter(attr => attr.name.startsWith('data-'))
                    .reduce((acc, attr) => { acc[attr.name.substring(5)] = attr.value; return acc; }, {}) || undefined,
                isInteractive: ['a', 'button', 'input', 'select', 'textarea'].includes(tagName) || element.hasAttribute('onclick') || element.hasAttribute('tabindex') || (element.hasAttribute('role') && element.getAttribute('role').includes('button')) || undefined,
                geometry: geometry || undefined,
                textContent: textContent || undefined,
                css: css || undefined,
            };

            if (tagName === 'a' && element.href) {
                data.href = element.href;
            }
            if (tagName === 'img' && element.src) {
                data.src = element.src;
                if (element.alt) data.alt = element.alt;
            }

            // Strukturbaum nur für wichtige oder Ad-Elemente
            if (isAdItem || (['h1', 'h2', 'h3', 'p'].includes(tagName) && textContent && textContent.length > 20)) {
                 const structureTree = buildRecursiveElementTree(element);
                 if (structureTree) data.structureTree = structureTree;
            }

            if (isAdItem) {
                const rawChildrenHTML = compactHTML(element.innerHTML);
                if (rawChildrenHTML) data.rawChildrenHTML = rawChildrenHTML;
            }

            // Entferne undefined-Werte
            Object.keys(data).forEach(key => data[key] === undefined && delete data[key]);

            return data;
        } catch (e) {
            logScriptError(e, `extractElementDetails for ${element.tagName}`);
            return null;
        }
    }

    function extractAdItemData(adListItem) {
        try {
            const baseData = extractElementDetails(adListItem, true); // True for isAdItem
            if (!baseData) return null;

            const keyData = {};
            const titleElement = adListItem.querySelector('h2.text-module-begin a.ellipsis, h2.text-module-begin span.ellipsis');
            if (titleElement) keyData.title = titleElement.textContent.trim();
            const descElement = adListItem.querySelector('.aditem-main--middle--description');
            if (descElement) keyData.description = descElement.textContent.trim().replace(/\s+/g, ' ');
            const priceElement = adListItem.querySelector('.aditem-main--middle--price-shipping--price');
            if (priceElement) keyData.price = parsePrice(priceElement.textContent);
            const locationElement = adListItem.querySelector('.aditem-main--top--left');
            if (locationElement) keyData.location = parseLocation(locationElement.textContent);
            const dateElement = adListItem.querySelector('.aditem-main--top--right');
            if (dateElement) keyData.date = dateElement.textContent.trim();
            const linkElement = adListItem.querySelector('a[href^="/s-anzeige/"]');
            if(linkElement) keyData.link = `https://www.kleinanzeigen.de${linkElement.getAttribute('href')}`;
            const imageCountElement = adListItem.querySelector('.galleryimage--counter');
            if (imageCountElement) keyData.image_count = parseInt(imageCountElement.textContent.trim(), 10);
            const isShippingPossible = adListItem.querySelector('.simpletag.tag-with-icon .icon-package');
            if (isShippingPossible) keyData.shipping_possible = true;
            const isDirectBuy = adListItem.querySelector('.simpletag.tag-with-icon .icon-send-money');
            if (isDirectBuy) keyData.direct_buy_possible = true;

            baseData.key_data = Object.keys(keyData).length > 0 ? keyData : undefined;
            return baseData;

        } catch (e) {
            logScriptError(e, 'extractAdItemData');
            return null;
        }
    }

    function extractPageMetadata() {
        try {
            const url = window.location.href;
            const title = document.title;
            const timestamp_utc = new Date().toISOString();
            const html_lang = document.documentElement.lang || 'de';
            const faviconLink = document.querySelector('link[rel="icon"], link[rel="shortcut icon"]');
            const favicon_url = faviconLink ? faviconLink.href : 'https://www.kleinanzeigen.de/favicon.ico';
            const metaDescriptionTag = document.querySelector('meta[name="description"]');
            const metaDescription = metaDescriptionTag ? metaDescriptionTag.content : null;
            const canonicalLink = document.querySelector('link[rel="canonical"]');
            const canonicalUrl = canonicalLink ? canonicalLink.href : null;

            const urlParams = new URLSearchParams(window.location.search);
            const url_parameters = {};
            for (const [key, value] of urlParams.entries()) {
                url_parameters[key] = value;
            }

            const schemaOrgData = [];
            document.querySelectorAll('script[type="application/ld+json"]').forEach(script => {
                try {
                    const json = JSON.parse(script.textContent);
                    if (json['@type']) {
                        // If it's an array of types, take them all
                        if (Array.isArray(json['@type'])) {
                            schemaOrgData.push(...json['@type']);
                        } else {
                            schemaOrgData.push(json['@type']);
                        }
                    }
                } catch (e) {
                    logScriptError(e, 'Parsing Schema.org JSON-LD');
                }
            });

            return {
                metadata: {
                    url: url,
                    title: title,
                    timestamp_utc: timestamp_utc,
                    html_lang: html_lang,
                    favicon_url: favicon_url,
                    url_parameters: Object.keys(url_parameters).length > 0 ? url_parameters : null,
                    metaDescription: metaDescription,
                    canonicalUrl: canonicalUrl,
                    schemaOrgData: schemaOrgData.length > 0 ? Array.from(new Set(schemaOrgData)) : null, // Unique types
                    script_runtime: ((performance.now() - scriptStartTime) / 1000).toFixed(3) + 's',
                    script_version: GM_info.script.version,
                    page_load_errors: scriptErrors.length > 0 ? scriptErrors : null
                }
            };
        } catch (e) {
            logScriptError(e, 'extractPageMetadata');
            return { metadata: { url: window.location.href, error: e.message, page_load_errors: scriptErrors } };
        }
    }

    function getDomPath(el) {
        if (!(el instanceof Element)) return;
        const path = [];
        let current = el;
        while (current.nodeType === Node.ELEMENT_NODE) {
            let selector = current.tagName.toLowerCase();
            if (current.id) {
                selector += '#' + current.id;
                path.unshift(selector);
                break;
            } else {
                let sib = current, nth = 1;
                while (sib.previousElementSibling) {
                    sib = sib.previousElementSibling;
                    if (sib.tagName.toLowerCase() === selector) nth++;
                }
                if (nth !== 1) selector += `:nth-of-type(${nth})`;
            }
            path.unshift(selector);
            current = current.parentNode;
            if (current && current.tagName === 'BODY') { // Stop at body to avoid overly long paths
                path.unshift('body');
                break;
            }
        }
        return path.join(' > ');
    }


    // --- HAUPTFUNKTIONEN & MODUS-STEUERUNG ---
    function collectCurrentPageData() {
        scriptErrors = []; // Fehler für jede neue Seite zurücksetzen
        const pageData = [extractPageMetadata()]; // Beginne immer mit Metadaten

        // Kleinanzeigen spezifische Ad-Items immer sammeln
        document.querySelectorAll('article.aditem, li.ad-listitem > article.aditem').forEach(item => {
            const data = extractAdItemData(item);
            if (data) pageData.push(data);
        });

        // Allgemeine sichtbare und lesbare Elemente, SEHR selektiv
        // Ziel: Haupt-Überschriften, Suchfelder, Filter, Paginierung, etc., die den Kontext der Seite beschreiben
        const significantSelectors = [
            'h1', // Hauptüberschrift der Seite
            'h2.headline-module', // Wichtige Unterüberschriften
            'input[name="keywords"]', // Suchfeld
            'button[data-e2e-id="search-button"]', // Such-Button
            '.pagination a', // Paginierungs-Links
            '.currentpage', // Aktuelle Seite in der Paginierung
            '.sortby-box', // Sortier-Dropdown
            '.filter-box' // Filter-Container (könnte weitere wichtige Elemente enthalten)
        ];

        document.querySelectorAll(significantSelectors.join(', ')).forEach(element => {
            // Ignoriere Elemente, die bereits Teil eines ad-listitem sind
            if (element.closest('.aditem')) return;

            const data = extractElementDetails(element);
            if (data && data.geometry && data.geometry.inViewport) {
                // Nur Elemente mit relevantem Textinhalt oder spezifischen Attributen sammeln
                if (data.textContent && data.textContent.trim().length > 0) {
                    pageData.push(data);
                } else if (element.tagName.toLowerCase() === 'input' && element.value) { // Für Input-Felder den Wert erfassen
                    data.value = element.value;
                    pageData.push(data);
                } else if (element.tagName.toLowerCase() === 'a' && element.href && (element.textContent || element.getAttribute('title') || element.getAttribute('aria-label'))) {
                    // Links, die entweder Text haben oder durch Title/Aria-Label beschrieben sind
                    pageData.push(data);
                } else if (element.tagName.toLowerCase() === 'button' && (element.textContent || element.getAttribute('title') || element.getAttribute('aria-label'))) {
                     // Buttons, die entweder Text haben oder durch Title/Aria-Label beschrieben sind
                    pageData.push(data);
                }
            }
        });

        // Aktualisiere Metadaten mit tatsächlicher Anzahl gesammelter Elemente
        pageData[0].metadata.selected_elements_count = pageData.length - 1; // Metadaten selbst nicht zählen
        console.log(`Kleinanzeigen-Skript: Gesammelte Elemente auf aktueller Seite: ${pageData.length - 1}`);
        return pageData;
    }

    function copyDataToClipboard(data) {
        try {
            const jsonlOutput = data.map(obj => JSON.stringify(obj)).join('\n');
            GM_setClipboard(jsonlOutput, 'text/plain');
            showNotification(`${data.length - 1} Objekt(e) als JSONL kopiert!`);
        } catch (e) {
            logScriptError(e, 'copyDataToClipboard');
            showNotification('Fehler beim Kopieren der Daten!');
        }
    }

    // --- BUTTON INTERAKTION ---
    function handleButtonPress(e) {
        if (e.button !== 0) return; // Nur linke Maustaste

        if (isLockedMode) {
            // Wenn im Sammel-Modus, beende den Modus und kopiere alles
            console.log("Kleinanzeigen-Skript: Sammel-Modus beenden durch Kurz-Klick.");
            deactivateLockedMode();
            return;
        }

        // Startet Timer für Langklick-Erkennung
        console.log("Kleinanzeigen-Skript: Maustaste gedrückt, starte Langklick-Timer.");
        longPressTimer = setTimeout(() => {
            console.log("Kleinanzeigen-Skript: Langklick-Schwelle erreicht, starte Countdown.");
            startCountdown(3); // Startet den visuellen Countdown
            longPressTimer = null; // Setzt Timer zurück, da Langklick erkannt
        }, LONG_PRESS_THRESHOLD);

        // Listener, um Langklick zu beenden, falls die Maustaste losgelassen wird
        // Oder wenn die Maus den Button verlässt, bevor der Langklick registriert wird
        pickerButton.addEventListener('mouseup', handleButtonRelease, { once: true });
        pickerButton.addEventListener('mouseleave', handleButtonRelease, { once: true });
    }

    function handleButtonRelease() {
        console.log("Kleinanzeigen-Skript: Maustaste losgelassen.");
        if (countdownInterval) {
            // Wenn der Countdown aktiv ist, wurde der Langklick abgebrochen
            console.log("Kleinanzeigen-Skript: Langklick-Countdown abgebrochen.");
            clearInterval(countdownInterval);
            countdownInterval = null;
            pickerButton.textContent = 'TO LLM'; // Text zurücksetzen
            pickerButton.classList.remove('active'); // Puls entfernen
            showNotification('Langklick abgebrochen.');
        } else if (longPressTimer) {
            // Wenn der Timer noch läuft, war es ein kurzer Klick
            console.log("Kleinanzeigen-Skript: Kurzer Klick erkannt.");
            clearTimeout(longPressTimer);
            longPressTimer = null;
            // Kurzer Klick: Sammle Daten der aktuellen Seite und kopiere sie
            const currentPageData = collectCurrentPageData();
            copyDataToClipboard(currentPageData);
            showNotification('Aktuelle Seite als JSONL kopiert!');
            pickerButton.classList.remove('active'); // Setze Zustand zurück
        }
        // Wenn weder countdownInterval noch longPressTimer aktiv sind, war es ein gültiger Langklick
        // oder ein Klick im LockedMode, der bereits von deactivateLockedMode behandelt wurde.
    }

    function startCountdown(count) {
        let currentCount = count;
        pickerButton.textContent = currentCount;
        pickerButton.classList.add('active'); // Visuell als "aktiv" markieren

        countdownInterval = setInterval(() => {
            currentCount--;
            if (currentCount > 0) {
                pickerButton.textContent = currentCount;
            } else {
                clearInterval(countdownInterval);
                countdownInterval = null;
                pickerButton.textContent = 'STOP'; // Text für Sammel-Modus
                activateLockedMode(); // Aktiviere den kontinuierlichen Sammel-Modus
                showNotification('Sammel-Modus aktiv. Navigieren Sie durch Seiten.');
            }
        }, 1000);
    }

    function activateLockedMode() {
        console.log("Kleinanzeigen-Skript: Sammel-Modus aktiviert.");
        isLockedMode = true;
        GM_setValue(STORAGE_KEY_LOCKED_MODE, true); // Status speichern
        allCollectedData = []; // Alte Daten löschen
        allCollectedData.push(...collectCurrentPageData()); // Daten der Startseite sammeln
        pickerButton.classList.remove('active'); // Entferne "active" Puls
        pickerButton.classList.add('locked'); // Füge "locked" Puls hinzu
        pickerButton.title = 'Klicken, um Sammel-Modus zu beenden und alle gesammelten Daten zu kopieren';

        // Starte den MutationObserver für Seitenwechsel in SPAs
        observeDOMChanges();

        updateStatusDisplay();
    }

    function deactivateLockedMode() {
        console.log("Kleinanzeigen-Skript: Sammel-Modus deaktiviert.");
        isLockedMode = false;
        GM_setValue(STORAGE_KEY_LOCKED_MODE, false); // Status löschen

        if (mutationObserver) {
            mutationObserver.disconnect();
            mutationObserver = null;
            console.log("Kleinanzeigen-Skript: MutationObserver gestoppt.");
        }

        // Kopiere alle gesammelten Daten vor dem Löschen
        copyDataToClipboard(allCollectedData);
        allCollectedData = []; // Gesammelte Daten leeren nach dem Kopieren/Beenden

        pickerButton.classList.remove('locked', 'active', 'processing');
        pickerButton.textContent = 'TO LLM'; // Zurück zum Standardtext
        pickerButton.title = 'Daten der aktuellen Seite erfassen (Kurz-Klick) / Kontinuierlich sammeln (Langklick)';

        removeStatus();
        showNotification('Sammel-Modus beendet.');
    }

    // --- SEITENWECHSEL-ERKENNUNG (Primär über MutationObserver) ---
    function processNewPage() {
        if (processingPage) {
            console.log("Kleinanzeigen-Skript: Seitenverarbeitung bereits im Gange, überspringe.");
            return;
        }
        processingPage = true;
        pickerButton.classList.add('processing'); // Visueller Hinweis
        console.log(`Kleinanzeigen-Skript: Starte Datensammlung für aktuelle Seite: ${window.location.href}`);

        // Kleine Verzögerung, damit die Seite vollständig gerendert werden kann
        // und um Doppel-Sammlungen bei schnellen DOM-Updates zu vermeiden
        setTimeout(() => {
            // Die URL wird beim Hinzufügen der Metadaten erfasst.
            // Die Entscheidung, ob eine Seite gesammelt wird, liegt nun allein beim MutationObserver.
            allCollectedData.push(...collectCurrentPageData());
            updateStatusDisplay();
            showNotification(`Seite gesammelt: ${window.location.pathname.split('/').pop() || 'Startseite'}`);
            console.log(`Kleinanzeigen-Skript: Datensammlung für ${window.location.href} abgeschlossen. Total Datenpunkte: ${allCollectedData.length}`);

            pickerButton.classList.remove('processing');
            processingPage = false;
        }, 800); // Erhöhte Verzögerung für stabilere Erfassung
    }

    function observeDOMChanges() {
        if (mutationObserver) {
            mutationObserver.disconnect(); // Vorherigen Observer trennen
        }

        const targetNode = document.body;
        // childList: Änderungen an den direkten Kindern
        // subtree: Änderungen in der gesamten Unterbaumstruktur
        // attributes: Änderungen an Attributen (z.B. style, class)
        const config = { childList: true, subtree: true, attributes: false };

        mutationObserver = new MutationObserver(mutationsList => {
            const newUrl = window.location.href;
            if (newUrl !== lastKnownUrl) {
                // URL hat sich geändert (Browser-Nav, PushState), behandle als Seitenwechsel
                console.log(`Kleinanzeigen-Skript: URL-Wechsel erkannt: ${lastKnownUrl} -> ${newUrl}`);
                if (isLockedMode) {
                    processNewPage();
                }
            } else {
                // URL ist gleich, aber DOM hat sich geändert (AJAX-Load, Filter)
                const adListContainer = document.getElementById('srchrslt-adtable'); // Der Container, der die Anzeigenliste hält
                const paginationContainer = document.querySelector('.pagination'); // Paginierungsbereich

                const relevantChange = mutationsList.some(mutation => {
                    // Prüfen, ob Nodes hinzugefügt wurden, die Anzeigen enthalten könnten
                    const addedRelevantNodes = Array.from(mutation.addedNodes).some(node =>
                        node.nodeType === Node.ELEMENT_NODE &&
                        (node.matches('.ad-listitem, .aditem') || node.querySelector('.ad-listitem, .aditem')) // Einzelne Anzeige oder Container mit Anzeigen
                    );

                    // Prüfen, ob im Anzeigen- oder Paginierungscontainer etwas entfernt oder hinzugefügt wurde
                    const changeInKeyContainer = (adListContainer && adListContainer.contains(mutation.target) && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) ||
                                                 (paginationContainer && paginationContainer.contains(mutation.target) && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0));

                    // Wenn eine Änderung im Anzeigen- oder Paginierungsbereich stattgefunden hat, ist es relevant.
                    // Oder wenn direkt relevante Nodes hinzugefügt wurden (z.B. neue Ad-Items).
                    if (addedRelevantNodes || changeInKeyContainer) {
                        console.log("Kleinanzeigen-Skript: Relevant Change Detected: ", mutation.type, " target: ", mutation.target);
                        if (mutation.addedNodes.length > 0) console.log("Added Nodes: ", mutation.addedNodes);
                        if (mutation.removedNodes.length > 0) console.log("Removed Nodes: ", mutation.removedNodes);
                        return true;
                    }
                    return false;
                });

                if (isLockedMode && relevantChange) {
                    console.log("Kleinanzeigen-Skript: DOM-Änderung erkannt, die auf neue Inhalte hindeutet (URL unverändert).");
                    processNewPage();
                }
            }
            lastKnownUrl = newUrl; // Aktualisiere die zuletzt bekannte URL nach der Verarbeitung
        });
        mutationObserver.observe(targetNode, config);
        console.log("Kleinanzeigen-Skript: MutationObserver gestartet.");
    }


    // --- UI-FUNKTIONEN ---
    function createPickerButton() {
        const button = document.createElement('button');
        button.id = 'element-picker-btn';
        button.textContent = 'TO LLM'; // Standardtext
        button.title = 'Daten der aktuellen Seite erfassen (Kurz-Klick) / Kontinuierlich sammeln (Langklick)';
        button.addEventListener('mousedown', handleButtonPress);
        document.body.appendChild(button);
        return button;
    }

    function showNotification(message) {
        const n = document.createElement('div');
        n.className = 'picker-notification';
        n.textContent = message;
        document.body.appendChild(n);
        setTimeout(() => { if (n.parentNode) n.parentNode.removeChild(n) }, 3000);
    }

    function updateStatusDisplay() {
        if (isLockedMode) {
            // Filtere nur die Metadaten-Objekte, um die URLs zu extrahieren
            const collectedPageUrls = new Set(allCollectedData.filter(d => d.metadata).map(d => d.metadata.url));
            showStatus(`Sammel-Modus aktiv. Gesammelte Seiten: ${collectedPageUrls.size}. Datenpunkte: ${allCollectedData.length}`);
        } else {
            removeStatus();
        }
    }

    function showStatus(message) {
        let s = document.getElementById('picker-status');
        if (!s) {
            s = document.createElement('div');
            s.id = 'picker-status';
            s.className = 'picker-status';
            document.body.appendChild(s);
        }
        s.textContent = message;
    }

    function removeStatus() {
        const e = document.getElementById('picker-status');
        if (e) e.parentNode.removeChild(e);
    }

    // Initialisierung
    async function init() {
        pickerButton = createPickerButton();
        console.log('Kleinanzeigen-Datenexport (TO LLM) v19.0 geladen.');

        // Prüfen, ob der Sammel-Modus zuvor aktiv war
        const storedLockedMode = await GM_getValue(STORAGE_KEY_LOCKED_MODE, false);
        if (storedLockedMode) {
            console.log('Kleinanzeigen-Skript: Sammel-Modus aus Speicher wiederhergestellt.');
            isLockedMode = true;
            pickerButton.classList.add('locked');
            pickerButton.textContent = 'STOP';
            pickerButton.title = 'Klicken, um Sammel-Modus zu beenden und alle gesammelten Daten zu kopieren';
            showNotification('Sammel-Modus wiederhergestellt.');
            // Daten der aktuellen Seite sammeln, wenn der Modus wiederhergestellt wird
            processNewPage();
            observeDOMChanges(); // Observer neu starten
            updateStatusDisplay();
        }
    }

    // Ausführung nach DOMContentLoaded oder sofort, falls schon geladen
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();

QingJ © 2025

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