Idealista Tracker

Rastreamento avançado com histórico completo e status de disponibilidade

// ==UserScript==
// @name         Idealista Tracker
// @namespace    http://tampermonkey.net/
// @version      8.5
// @description  Rastreamento avançado com histórico completo e status de disponibilidade
// @author       Isidro Vila Verde
// @match        https://www.idealista.pt/*
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.listValues
// @grant        GM.deleteValue
// @run-at       document-idle
// ==/UserScript==

(async () => {
    'use strict';
    const STORAGE_PREFIX = 'idealista_tracker_v7_';
    const DEBOUNCE_DELAY = 500;

    // Configuration
    const config = {
        isScriptUpdatingUI: false,
        refreshTimeout: null,
        priceFormatter: new Intl.NumberFormat('pt-PT', {
            style: 'currency',
            currency: 'EUR',
            maximumFractionDigits: 0
        }),
        translations: {
            transaction: { rent: 'Arrendar', sale: 'Comprar' },
            property: {
                houses: 'Casas',
                apartments: 'Apartamentos',
                rooms: 'Quartos',
                offices: 'Escritórios',
                parking: 'Garagens',
                lands: 'Terrenos'
            },
            sorting: {
                'precos-desc': '↓ Preço',
                'precos-asc': '↑ Preço',
                'atualizado-desc': '↓ Atualizado',
                'area-desc': '↓ Área',
                'default': 'Padrão'
            }
        },
        names: {
            type: 'Tipo',
            price: 'Preço',
            area: 'Área',
            firstSeen: '1ª Detecção',
            lastUpdated: 'Últ. Atualização',
            variation: 'Variação',
            link: 'Link'
        }
    };

    // Helper functions
    const formatPrice = price => config.priceFormatter.format(price);

    const formatDate = isoDate => {
        if (!isoDate) return 'N/A';
        const date = new Date(isoDate);
        const pad = num => num.toString().padStart(2, '0');
        return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ` +
               `${pad(date.getHours())}:${pad(date.getMinutes())}`;
    };

    const getCurrentISODate = () => new Date().toISOString();

    const parsePrice = text => parseInt(text.replace(/[^\d]/g, '')) || 0;
    const parseArea = text => parseInt((text.match(/(\d+)\s*m²/) || [])[1]) || 0;

    // URL and context handling
    const isListingPage = () => {
        const path = window.location.pathname.toLowerCase();
        return /\/(comprar|arrendar)-.+/.test(path) &&
               !/(imovel|blog|ajuda|contato|mapa$|pagina-\d+$)/.test(path);
    };

    const getPageContext = () => {
        const url = new URL(window.location.href);
        const path = url.pathname;

        // Expressão regular para extrair transação, tipo, localização e sublocalização
        // const pathRegex = /(?:\/[^\/]+)*\/(?<trans>arrendar|comprar|venda)-(?<type>casas|apartamentos?|quartos?|escritorios?|garage(?:m|ns)|terrenos?|[\w-]+)(?:\/(?<loc>[^\/]+)(?:\/(?<subLoc>[^\/]+).*(?:\/(?<restri>com-[^\/]+))?)?)?\/?/i;
        const pathRegex = /^(?:\/[^\/]+)*\/(?<trans>arrendar|comprar)-(?<type>casas|apartamentos?|quartos?|escritorios?|garage(?:m|ns)|terrenos?|[\w-]+)(?:\/|$)(?:(?<loc>(?!com-)[^\/]+)(?:\/|$)(?:(?<subLoc>(?:(?!com-)[^\/]+))(?:\/|$))*)?(?<restri>(?<=\/)com-[^\/]*)?\/?$/i;
        const match = path.match(pathRegex) || {};
        const { trans, type, loc, subLoc, restri } = match.groups || {};

        // Extrair parâmetros de busca
        const searchParams = new URLSearchParams(url.search);

        return {
            isAreaSearch: searchParams.has('shape'),
            transactionType: { arrendar: 'rent', comprar: 'sale', venda: 'sale' }[trans?.toLowerCase()] || '',
            propertyType: {
                casas: 'houses',
                apartamentos: 'apartments',
                quarto: 'rooms',
                quartos: 'rooms',
                escritorios: 'offices',
                garagens: 'parking',
                terrenos: 'lands'
            }[type?.toLowerCase()] || type,
            location: loc || '',
            subLocation: subLoc || '',
            restri: restri || '',
            ordem: searchParams.get('ordem') || 'default'
        };
    };

    const getStorageKey = ctx =>
        `${STORAGE_PREFIX}${ctx.isAreaSearch ? 'area_' : ''}${ctx.transactionType}_${ctx.propertyType}_` +
        `${ctx.location}_${ctx.subLocation}_${ctx.ordem}_${ctx.restri}`.replace(/(?<=_)_+|_+$/g, '');

    // Data management
    // Add this at the beginning of the script, after config declaration
    const allDataCache = {};

    // Add this function to load all data at startup
    const loadAllData = async () => {
        const keys = (await GM.listValues()).filter(k => k.startsWith(STORAGE_PREFIX));
        const values = await Promise.all(keys.map(key => GM.getValue(key, '{}')));

        keys.forEach((key, index) => {
            allDataCache[key] = JSON.parse(values[index]);
        });
    };

    // Add this function to save all data when leaving the page
    const saveAllData = async () => {
        await Promise.all(Object.keys(allDataCache).map(key =>
            GM.setValue(key, JSON.stringify(allDataCache[key]))
        ));
    };

    const loadData = async (ctx) => {
        const key = getStorageKey(ctx);

        // If we don't have this key in cache yet, load it from storage
        if (!allDataCache[key]) {
            try {
                const data = await GM.getValue(key, '{}');
                allDataCache[key] = JSON.parse(data) || {};
            } catch (e) {
                console.error('Erro ao carregar dados:', e);
                allDataCache[key] = {};
            }
        }

        return allDataCache[key];
    };

    const saveData = async (ctx, data) => {
        const key = getStorageKey(ctx);
        allDataCache[key] = data; // Update our in-memory copy

        // We don't save to storage immediately - will save on page unload
        // This avoids duplicate writes during normal operation
    };

    const updatePropertyStatus = (id, isActive, status) => {
        // 1. Get all keys where `allDataCache[key][id]` exists
        const validKeys = Object.keys(allDataCache).filter(
            key => allDataCache[key]?.[id]
        );

        // 2. Find the most recent `lastSeen` (or use current time if none exists)
        const mostRecentLastSeen = validKeys.reduce((latest, key) => {
            const entryLastSeen = allDataCache[key][id].lastSeen;
            return (entryLastSeen && entryLastSeen > latest) ? entryLastSeen : latest;
        }, ""); // Default: empty string (falsy)

        // 3. Update all valid entries
        validKeys.forEach(key => {
            allDataCache[key][id].lastSeen = mostRecentLastSeen || getCurrentISODate();
            allDataCache[key][id].isActive = isActive;
            allDataCache[key][id].status = status;
        });
    };
    // Function to find the oldest record of a property across all contexts
    const findOldestPropertyRecord = (propertyId) =>
        Object.values(allDataCache)
            .flatMap(context => context[propertyId] || [])
            .reduce((oldest, record) => (
                (!oldest || new Date(record.firstSeen) < new Date(oldest.firstSeen))
                    ? record
                    : oldest
            ), null);

    // Property extraction
    const extractPropertyInfo = item => {
        const link = item.querySelector('a.item-link[href^="/imovel/"]');
        if (!link) return null;

        const url = 'https://www.idealista.pt' + link.getAttribute('href');
        const id = (url.match(/imovel\/(\d+)/) || [])[1];
        if (!id) return null;

        const priceText = item.querySelector('.price-row .item-price')?.textContent || '';
        const typologyText = item.querySelector('.item-detail-char .item-detail:first-child')?.textContent || '';
        const areaText = Array.from(item.querySelectorAll('.item-detail-char .item-detail'))
            .find(el => el.textContent.includes('m²'))?.textContent || '';

        return {
            id,
            url,
            price: parsePrice(priceText),
            typology: (typologyText.match(/(T\d+|Quarto|Estúdio)/i) || [])[0] || typologyText,
            area: parseArea(areaText) + ' m²',
            hasGarage: !!item.querySelector('.item-parking, [title*="garagem"]'),
            isActive: true
        };
    };

    // UI Components
    GM_addStyle(`
#idealistaPanel { position: fixed; top: 10px; right: 10px; width: 850px; max-height: 90vh; background: white; border: 1px solid #e0e0e0; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); z-index: 10000; font-family: 'Segoe UI', Arial, sans-serif; display: flex; flex-direction: column; overflow: hidden; resize: both; min-width: 400px; min-height: 300px; }
#idealistaHeader { padding: 12px 15px; background: #34495e; color: white; border-radius: 8px 8px 0 0; display: flex; justify-content: space-between; align-items: center; cursor: move; user-select: none; }
#idealistaContent { overflow-y: auto; flex-grow: 1; padding: 0 5px; }
#idealistaTable { width: 100%; border-collapse: collapse; font-size: 13px; }
#idealistaTable th { position: sticky; top: 0; background: #2c3e50; color: white; padding: 8px 10px; text-align: left; font-weight: 500; cursor: pointer; }
#idealistaTable td { padding: 8px 10px; border-bottom: 1px solid #ecf0f1; vertical-align: top; }
.price-cell { font-weight: bold; white-space: nowrap; }
.price-up { color: #e74c3c; }
.price-down { color: #27ae60; }
.price-same { color: #3498db; }
.status-active-row { background-color: #e8f5e9 !important; }
.status-inactive-row { background-color: #ffebee !important; }
.sort-arrow { margin-left: 5px; }
.idealista-button { background: #2c3e50; color: white; border: none; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 12px; margin-left: 5px; }
.idealista-button.danger { background: #e74c3c; }
.context-badge { background: #9b59b6; color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; margin-left: 8px; }
#idealistaFooter { padding: 10px; background: #f5f5f5; border-top: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; }
th[data-column].sorted-asc::after { content: " ↑"; margin-left: 5px; display: inline-block; }
th[data-column].sorted-desc::after { content: " ↓"; margin-left: 5px; display: inline-block; }
#idealistaPanel::after { content: ''; position: absolute; bottom: 2px; right: 2px; width: 12px; height: 12px; background: linear-gradient(135deg, #ccc 0%, #ccc 50%, transparent 50%); cursor: nwse-resize; }
#idealistaContent::-webkit-scrollbar { width: 8px; height: 8px; }
#idealistaContent::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
#idealistaContent::-webkit-scrollbar-thumb { background: #888; border-radius: 4px; }
#idealistaContent::-webkit-scrollbar-thumb:hover { background: #555; }
`);

    const setupTableSorting = () => {
        const table = document.getElementById('idealistaTable');
        if (!table) return;

        // Define our sort functions by data-column values
        const sortFunctions = {
            type: (a, b) => a.localeCompare(b),
            price: (a, b) => parsePrice(a) - parsePrice(b),
            area: (a, b) => parseArea(a) - parseArea(b),
            firstSeen: (a, b) => new Date(a) - new Date(b),
            lastUpdated: (a, b) => new Date(a) - new Date(b)
        };

        let currentSort = { column: null, direction: 1 };

        table.querySelectorAll('th[data-column]').forEach(header => {
            const columnName = header.dataset.column;
            if (!sortFunctions[columnName]) return;

            header.style.cursor = 'pointer';

            header.addEventListener('click', () => {
                // Remove sorting classes from all headers
                table.querySelectorAll('th[data-column]').forEach(h => {
                    h.classList.remove('sorted-asc', 'sorted-desc');
                });

                // Update sort direction
                if (currentSort.column === columnName) {
                    currentSort.direction *= -1;
                } else {
                    currentSort.column = columnName;
                    currentSort.direction = 1;
                }

                // Add appropriate sorting class
                header.classList.add(
                    currentSort.direction === 1 ? 'sorted-asc' : 'sorted-desc'
                );

                // Sort table
                const tbody = table.querySelector('tbody');
                const rows = Array.from(tbody.rows);
                const columnIndex = Array.from(header.parentNode.children).indexOf(header);

                rows.sort((a, b) => {
                    const aVal = a.cells[columnIndex].textContent.trim();
                    const bVal = b.cells[columnIndex].textContent.trim();
                    return sortFunctions[columnName](aVal, bVal) * currentSort.direction;
                });

                // Re-insert sorted rows
                rows.forEach(row => tbody.appendChild(row));
            });
        });
    };

    const translateContext = ctx => {
        const { transaction, property, sorting } = config.translations;
        const loc = ctx.location ?
            ` em ${ctx.location.replace(/-/g, ' ')}${ctx.subLocation ? ` > ${ctx.subLocation.replace(/-/g, ' ')}` : ''}` : '';

        return `${transaction[ctx.transactionType]} ${property[ctx.propertyType] || ctx.propertyType}${loc} | ${sorting[ctx.ordem] || ctx.ordem}`;
    };

    const renderPriceTrend = prop => {
        if (!prop.history?.length) return '<span class="price-same">→ Estável</span>';

        const last = prop.history[prop.history.length - 1];
        const diff = last.change;
        const absDiff = Math.abs(diff);
        const pct = Math.round((absDiff / last.oldPrice) * 100);

        if (diff > 0) return `<span class="price-up">↑ +${formatPrice(absDiff)} (+${pct}%)</span>`;
        if (diff < 0) return `<span class="price-down">↓ -${formatPrice(absDiff)} (-${pct}%)</span>`;
        return '<span class="price-same">→ Igual</span>';
    };

    const createPropertyRow = prop => `
        <tr class="${prop.isActive ? 'status-active-row' : 'status-inactive-row'}">
            <td>${prop.typology}</td>
            <td class="price-cell">${formatPrice(prop.price)}</td>
            <td>${renderPriceTrend(prop)}</td>
            <td>${prop.area}</td>
            <td>${formatDate(prop.firstSeen)}</td>
            <td>${formatDate(prop.lastSeen)}</td>
            <td><a href="${prop.url}" target="_blank">${prop.id}</a></td>
        </tr>
    `;

    const createUI = async () => {
        if (config.isScriptUpdatingUI) return;
        config.isScriptUpdatingUI = true;

        try {
            const ctx = getPageContext();
            if (!ctx.transactionType) return;

            const currentItems = Array.from(document.querySelectorAll('article.item'));
            const currentIds = currentItems
                .map(item => {
                    const href = item.querySelector('a.item-link[href^="/imovel/"]')?.getAttribute('href');
                    return href?.match(/imovel\/(\d+)/)?.[1];
                })
                .filter(Boolean);

            const data = await loadData(ctx);
            let newCount = 0;

            for (const item of currentItems) {
                const prop = extractPropertyInfo(item);
                if (!prop) continue;

                if (!data[prop.id]) {
                    console.log('Try to find old record for', prop.id);
                    // Check for existing records in other contexts
                    const oldestRecord = await findOldestPropertyRecord(prop.id);
                    if (oldestRecord) {
                        console.log('Found old record', oldestRecord);
                        data[prop.id] = oldestRecord
                    } else {
                        newCount++;
                        data[prop.id] = {
                            firstSeen: getCurrentISODate(),
                            initialPrice: prop.price
                        }
                        console.log('New record', data[prop.id]);
                    }

                    // Set location/subLocation if not area search
                    if (!ctx.isAreaSearch) {
                        prop.location = ctx.location;
                        prop.subLocation = ctx.subLocation;
                    }
                }

                // Always update with current data
                data[prop.id] = {
                    ...data[prop.id],
                    ...prop,
                    lastSeen: getCurrentISODate(),
                    isActive: true,
                    status: 'listed'
                };
            }

            if (!ctx.isAreaSearch) {
                await Promise.all(
                    Object.keys(data)
                        .filter(id => !currentIds.includes(id))
                        .map(async id => {
                            try {
                                const response = await fetch(data[id].url, {
                                    method: 'HEAD', // Only fetch headers for efficiency
                                    credentials: 'include'
                                });

                                if (response.status === 404) {
                                    console.log('Property completely removed');
                                    data[id].isActive = false;
                                    data[id].status = 'removed';
                                } else {
                                    console.log(`Property ${id} exists but not in current search`);
                                    data[id].isActive = true;
                                    data[id].status = 'notlisted';
                                }
                            } catch (error) {
                                console.error('Network error or other issue');
                                data[id].isActive = null;
                                data[id].status = 'error';
                            } finally {
                                const { isActive, status } = data[id];
                                await updatePropertyStatus(id, isActive, status);
                            }
                        })
                );
            }

            await saveData(ctx, data);

            const displayData = Object.values(data)
             .filter(prop => prop.status !== 'error' && prop.status !== 'notlisted')
             .sort((a, b) =>
                  new Date(b.lastSeen) - new Date(a.lastSeen)
             );

            const panel = document.createElement('div');
            panel.id = 'idealistaPanel';
            panel.innerHTML = `
                <div id="idealistaHeader">
                    <h3>📊 Idealista Tracker <span class="context-badge">${translateContext(ctx)}</span></h3>
                    <button id="idealistaClose">✕</button>
                </div>
                <div id="idealistaContent">
                    <table id="idealistaTable">
                        <thead>
                            <tr>
                                <th data-column="type">${config.names.type}</th>
                                <th data-column="price">${config.names.price}</th>
                                <th data-column="variation">${config.names.variation}</th>
                                <th data-column="area">${config.names.area}</th>
                                <th data-column="firstSeen">${config.names.firstSeen}</th>
                                <th data-column="lastUpdated">${config.names.lastUpdated}</th>
                                <th data-column="link">${config.names.link}</th>
                            </tr>
                        </thead>
                        <tbody>
                            ${displayData.map(createPropertyRow).join('')}
                        </tbody>
                    </table>
                </div>
                <div id="idealistaFooter">
                    <div>${currentItems.length} ativos | ${displayData.length} totais | ${newCount} novos</div>
                    <div>
                        <button id="idealistaExport" class="idealista-button">📁 Exportar</button>
                        <button id="idealistaClearContext" class="idealista-button danger">🗑️ Limpar Esta Pesquisa</button>
                    </div>
                </div>
            `;

            // Event listeners
            panel.querySelector('#idealistaClose').addEventListener('click', () => panel.remove());

            panel.querySelector('#idealistaExport').addEventListener('click', async () => {
                const headers = [
                    'ID', 'Tipologia', 'Preço Inicial', 'Preço Atual', 'Primeira Detecção',
                    'Última Atualização', 'Status', 'Área', 'Garagem', 'Localidade', 'Sub-localidade', 'URL'
                ];

                const rows = displayData.map(p => [
                    p.id,
                    p.typology,
                    p.initialPrice,
                    p.price,
                    p.firstSeen,
                    p.lastSeen,
                    p.isActive ? 'Ativo' : 'Inativo',
                    p.area,
                    p.hasGarage ? 'Sim' : 'Não',
                    p.location || '',
                    p.subLocation || '',
                    p.url
                ]);

                const csvContent = [headers, ...rows]
                    .map(row => row.map(field => `"${field.toString().replace(/"/g, '""')}"`).join(';'))
                    .join('\n');

                await GM_setClipboard(csvContent, 'text');
                alert(`Dados exportados para ${displayData.length} imóveis! Copiado para o clipboard.`);
            });

            panel.querySelector('#idealistaClearContext').addEventListener('click', async () => {
                if (confirm(`⚠️ Apagar TODOS os dados para:\n"${translateContext(ctx)}"?\nEsta ação não pode ser desfeita.`)) {
                    await GM.deleteValue(getStorageKey(ctx));
                    panel.remove();
                }
            });

            document.body.appendChild(panel);
            setupTableSorting();
            // Add draggable
            (function enableDraggableIdealistaPanel() {
                    const panel = document.getElementById('idealistaPanel');
                    if (!panel) return;

                    const header = document.getElementById('idealistaHeader');
                    if (!header) return;

                    panel.style.position = 'fixed';
                    panel.style.top = '50px';
                    panel.style.right = '20px';

                    let isDragging = false;
                    let offsetX = 0;
                    let offsetY = 0;

                    header.style.cursor = 'move';

                    header.addEventListener('mousedown', (e) => {
                        isDragging = true;
                        offsetX = e.clientX - panel.offsetLeft;
                        offsetY = e.clientY - panel.offsetTop;
                        e.preventDefault();
                    });

                    document.addEventListener('mousemove', (e) => {
                        if (isDragging) {
                            panel.style.left = `${e.clientX - offsetX}px`;
                            panel.style.top = `${e.clientY - offsetY}px`;
                        }
                    });

                    document.addEventListener('mouseup', () => {
                        isDragging = false;
                    });
            })();

        } catch (error) {
            console.error('Erro ao criar UI:', error);
        } finally {
            config.isScriptUpdatingUI = false;
        }
    };

    // Initialization and DOM observation
    const refreshUI = async () => {
        clearTimeout(config.refreshTimeout);
        document.getElementById('idealistaPanel')?.remove();
        await createUI();
    };

    const setupDOMObserver = () => {
        const observer = new MutationObserver(mutations => {
            if (config.isScriptUpdatingUI) return;

            const hasRelevantChanges = mutations.some(mutation => {
                // Ignore changes within our own panel
                if (mutation.target.id === 'idealistaPanel' ||
                    mutation.target.closest('#idealistaPanel')) {
                    return false;
                }

                // Check for added property items
                return Array.from(mutation.addedNodes).some(node => {
                    return node.nodeType === 1 &&
                          (node.matches('article.item') || node.querySelector('article.item'));
                });
            });

            if (hasRelevantChanges) {
                clearTimeout(config.refreshTimeout);
                config.refreshTimeout = setTimeout(refreshUI, DEBOUNCE_DELAY);
            }
        });

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

    await loadAllData(); // Cache all data at once
    window.addEventListener('beforeunload', saveAllData);
    const init = async () => {
        if (!isListingPage()) {
            console.log('[Idealista] Não é página de listagem - script não será executado');
            return;
        }



        // If items are already loaded
        if (document.querySelector('article.item')) {
            await createUI();
            setupDOMObserver();
            return;
        }

        // Wait for items to load
        const observer = new MutationObserver((mutations, obs) => {
            if (document.querySelector('article.item')) {
                obs.disconnect();
                createUI();
                setupDOMObserver();
            }
        });

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

        // Cleanup after 15 seconds if nothing loads
        setTimeout(() => observer.disconnect(), 15000);
    };

    // Start the script
    if (document.readyState === 'complete') {
        setTimeout(init, 1000);
    } else {
        window.addEventListener('load', () => setTimeout(init, 1000));
    }
})();