您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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)); } })();