one button click -> simplify propertyguru listing info for easily copy / paste
// ==UserScript== // @name PropertyguruAssist // @namespace http://tampermonkey.net/ // @version 1.3 // @description one button click -> simplify propertyguru listing info for easily copy / paste // @author EnginePlus // @match https://*.propertyguru.com.sg/listing/* // @match https://*.commercialguru.com.sg/listing/* // @match https://*.propertyguru.com.my/property-listing/* // @grant GM_xmlhttpRequest // @connect www.99.co // @connect 99.co // @resource customCSS https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css // @require https://greasyfork.org/scripts/27254-clipboard-js/code/clipboardjs.js?version=174357 // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.slim.min.js // ==/UserScript== (function () { 'use strict'; function getValueByLabel(items, label) { if (!Array.isArray(items)) return 'N.A.'; let item = items.find(item => item.label === label || item.icon === label); return item ? item.value : 'N.A.'; } function safe(fn, fallback = 'N.A.') { try { const val = fn(); return (val !== undefined && val !== null && val !== '') ? val : fallback; } catch { return fallback; } } function isLoginRequired() { const buttons = Array.from(document.querySelectorAll('div.btn-content')); return buttons.some(el => el.textContent.trim().toLowerCase() === 'login'); } function normalizePhone(raw) { if (!raw || raw === 'N.A.') return 'N.A.'; const text = String(raw).trim(); if (/[Xx*]/.test(text)) return text; const digits = text.replace(/\D/g, ''); if (digits.length < 8) return text; return digits.startsWith('65') ? '+' + digits : '+65' + digits; } function needs99CoPhoneLookup(phoneNumber) { if (!phoneNumber || phoneNumber === 'N.A.') return true; const text = String(phoneNumber); const digits = text.replace(/\D/g, ''); return /[Xx*]/.test(text) || digits.length < 8; } function parseCeaFromPage() { const text = document.body ? document.body.innerText : ''; const match = text.match(/CEA:\s*(R\d{6}[A-Z])\s*[\/·]\s*(L\d{7}[A-Z])/i); return { ceaNumber: match ? match[1].toUpperCase() : '', agencyLicense: match ? match[2].toUpperCase() : '' }; } function isSingaporePropertyGuruListing() { return /\.(propertyguru|commercialguru)\.com\.sg$/i.test(window.location.hostname) && /^\/listing\//i.test(window.location.pathname); } function decodeHtmlEntitiesLite(str) { return String(str || '') .replace(/"/g, '"') .replace(/"/g, '"') .replace(/&/g, '&') .replace(///g, '/') .replace(/</g, '<') .replace(/>/g, '>'); } function escapeRegExp(str) { return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function fetchTextByGm(url) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error('GM_xmlhttpRequest is unavailable')); return; } GM_xmlhttpRequest({ method: 'GET', url, timeout: 15000, headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', Referer: 'https://www.99.co/' }, onload: response => { if (response.status >= 200 && response.status < 400) { resolve(response.responseText || ''); } else { reject(new Error('99.co returned HTTP ' + response.status)); } }, onerror: () => reject(new Error('99.co request failed')), ontimeout: () => reject(new Error('99.co request timed out')) }); }); } function verify99CoAgentPage(html, expectedCea, expectedAgencyLicense) { const ceaOk = new RegExp('\\b' + escapeRegExp(expectedCea) + '\\b', 'i').test(html); if (!ceaOk) { return { ok: false, reason: '99.co页面CEA不匹配' }; } if (expectedAgencyLicense) { const agencyCodes = Array.from(new Set((html.match(/\bL\d{7}[A-Z]\b/gi) || []).map(x => x.toUpperCase()))); if (agencyCodes.length > 0 && !agencyCodes.includes(expectedAgencyLicense.toUpperCase())) { return { ok: false, reason: '99.co页面公司注册号不匹配' }; } } return { ok: true, reason: '' }; } function parse99CoContact(htmlRaw, expectedCea, expectedAgencyLicense) { const html = decodeHtmlEntitiesLite(htmlRaw); const verification = verify99CoAgentPage(html, expectedCea, expectedAgencyLicense); if (!verification.ok) return { ok: false, reason: verification.reason }; const rawNumber = safe(() => html.match(/"raw_number"\s*:\s*"(\+65\d{8})"/)[1], ''); const phones = Array.from(html.matchAll(/"(?:phone|whatsapp)"\s*:\s*"(\+65\d{8})"/g)).map(m => m[1]); const uniquePhones = Array.from(new Set(phones)); const phone = rawNumber || uniquePhones[0] || ''; if (!phone) { return { ok: false, reason: '99.co页面源码没有完整电话字段' }; } return { ok: true, phone, sourceField: rawNumber ? 'raw_number' : 'phone/whatsapp' }; } async function enrichPhoneFrom99Co(data) { if (!isSingaporePropertyGuruListing()) return data; if (!data.ceaNumber) { data.phoneSource = data.phoneSource || 'PropertyGuru'; data.phoneLookupNote = '未找到CEA,未查询99.co'; return data; } if (!needs99CoPhoneLookup(data.phoneNumber)) return data; const url = 'https://www.99.co/singapore/agents/' + encodeURIComponent(data.ceaNumber); data.phoneLookupUrl = url; try { const html = await fetchTextByGm(url); const result = parse99CoContact(html, data.ceaNumber, data.agencyLicense); if (result.ok) { data.phoneNumber = result.phone; data.phoneSource = '99.co ' + result.sourceField; data.phoneLookupNote = '99.co / CEA No. 已校验 / ' + result.sourceField; } else { data.phoneSource = data.phoneSource || 'PropertyGuru'; data.phoneLookupNote = result.reason; } } catch (err) { data.phoneSource = data.phoneSource || 'PropertyGuru'; data.phoneLookupNote = '99.co查询失败:' + (err && err.message ? err.message : err); } return data; } function extractData() { const jsonData = safe(() => JSON.parse(document.getElementById('__NEXT_DATA__').textContent), {}); const root = jsonData?.props?.pageProps?.pageData?.data; const metatableItems = safe(() => root.detailsData.metatable.items, []); const ceaInfo = parseCeaFromPage(); const propertyGuruPhone = normalizePhone(safe(() => root.listingData.agent.mobile || root.contactAgentData.contactAgentCard.contactActions?.[0]?.phoneNumber)); return { url: window.location.href, propertyName: safe(() => root.listingData.localizedTitle), tenureType: getValueByLabel(metatableItems, 'calendar-days-o'), topYear: getValueByLabel(metatableItems, 'document-with-lines-o'), totalUnits: getValueByLabel(metatableItems, 'block-o'), bedNum: safe(() => root.listingData.bedrooms), bathNum: safe(() => root.listingData.bathrooms), floorSize: safe(() => root.listingData.floorArea), price: safe(() => root.propertyOverviewData.propertyInfo.price.amount), agentName: safe(() => root.contactAgentData.contactAgentCard.agentInfoProps.agent.name), phoneNumber: propertyGuruPhone, phoneSource: needs99CoPhoneLookup(propertyGuruPhone) ? 'PropertyGuru未提供完整号码' : 'PropertyGuru', phoneLookupNote: '', phoneLookupUrl: '', ceaNumber: ceaInfo.ceaNumber, agencyLicense: ceaInfo.agencyLicense }; } function copyToClipboard(data, checkboxes, button) { const get = key => checkboxes[key]?.checked ? (data[key] || 'N.A.') : ''; const mainInfo = get('propertyName') + ' [' + get('tenureType') + ' / ' + get('topYear') + ' / ' + get('totalUnits') + ']' + ', ' + get('bedNum') + ' Bed, ' + get('bathNum') + ' Bath, ' + get('floorSize') + ' sqft, ' + get('price'); const agentInfo = get('agentName') + ' ' + get('phoneNumber'); const clipboardText = data.url + '\t' + mainInfo + '\t' + agentInfo; navigator.clipboard.writeText(clipboardText).then(() => { if (button) { button.textContent = '已复制!'; setTimeout(() => (button.textContent = '复制到剪贴板'), 2000); } }); } function formatPhoneNumber(raw) { const phone = String(raw || '').replace(/\D/g, ''); return phone.startsWith('65') ? phone : '65' + phone; } function buildWhatsAppLink(agentName, listingUrl, rawPhone) { const phone = formatPhoneNumber(rawPhone); const message = `Hi ${agentName}, My client would like to learn more about your listing: ${listingUrl}`; const encodedText = encodeURIComponent(message); return `https://api.whatsapp.com/send?phone=${phone}&text=${encodedText}`; } function createPanel(data) { const groups = [ { key: 'infoBlock', label: '房源基本信息', fields: ['propertyName', 'tenureType', 'topYear', 'totalUnits', 'bedNum', 'bathNum', 'floorSize', 'price'] }, { key: 'agentBlock', label: '联系人信息', fields: ['agentName', 'ceaNumber', 'agencyLicense', 'phoneNumber'] } ]; const panel = document.createElement('div'); panel.style.position = 'fixed'; panel.style.bottom = '20px'; panel.style.right = '20px'; panel.style.background = '#fdfdfd'; panel.style.border = '1px solid #ccc'; panel.style.borderRadius = '8px'; panel.style.boxShadow = '2px 2px 12px rgba(0,0,0,0.2)'; panel.style.fontSize = '14px'; panel.style.minWidth = '320px'; panel.style.zIndex = 9999; panel.style.cursor = 'move'; panel.setAttribute('id', 'floatingPanel'); const header = document.createElement('div'); header.style.display = 'flex'; header.style.justifyContent = 'space-between'; header.style.alignItems = 'center'; header.style.padding = '8px'; header.style.backgroundColor = '#4A90E2'; header.style.color = '#fff'; header.style.borderTopLeftRadius = '8px'; header.style.borderTopRightRadius = '8px'; header.style.cursor = 'move'; const titleText = document.createElement('span'); titleText.textContent = 'v1.3 小助手提取信息'; titleText.style.fontWeight = 'bold'; header.appendChild(titleText); const toggleBtn = document.createElement('button'); toggleBtn.textContent = '最小化'; toggleBtn.style.marginLeft = 'auto'; toggleBtn.style.marginRight = '5px'; toggleBtn.style.fontSize = '12px'; toggleBtn.style.padding = '2px 6px'; toggleBtn.style.cursor = 'pointer'; toggleBtn.style.border = 'none'; toggleBtn.style.borderRadius = '4px'; toggleBtn.style.background = '#fff'; toggleBtn.style.color = '#4A90E2'; let isCollapsed = false; toggleBtn.onclick = () => { isCollapsed = !isCollapsed; content.style.display = isCollapsed ? 'none' : 'block'; toggleBtn.textContent = isCollapsed ? '展开' : '最小化'; }; header.appendChild(toggleBtn); if (isLoginRequired() && needs99CoPhoneLookup(data.phoneNumber)) { const warn = document.createElement('span'); warn.textContent = '未登录无法显示电话号码'; warn.style.color = 'red'; warn.style.fontWeight = 'normal'; warn.style.fontSize = '12px'; warn.style.marginLeft = '10px'; header.appendChild(warn); } panel.appendChild(header); const content = document.createElement('div'); content.style.padding = '10px'; content.style.backgroundColor = '#ffffff'; const checkboxes = {}; function createPlainInfoRow(labelText, valueText) { const wrapper = document.createElement('div'); wrapper.style.marginBottom = '4px'; const label = document.createElement('span'); label.textContent = labelText + ': '; label.style.fontWeight = 'bold'; const valueSpan = document.createElement('span'); valueSpan.style.color = '#333'; valueSpan.textContent = valueText || 'N.A.'; wrapper.appendChild(label); wrapper.appendChild(valueSpan); content.appendChild(wrapper); } function createPlainLinkRow(labelText, href) { if (!href) return; const wrapper = document.createElement('div'); wrapper.style.marginBottom = '4px'; const label = document.createElement('span'); label.textContent = labelText + ': '; label.style.fontWeight = 'bold'; const link = document.createElement('a'); link.href = href; link.textContent = '打开99.co Agent页'; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.style.color = '#1a73e8'; wrapper.appendChild(label); wrapper.appendChild(link); content.appendChild(wrapper); } groups.forEach(group => { const title = document.createElement('div'); title.textContent = group.label; title.style.fontWeight = 'bold'; title.style.marginTop = '10px'; content.appendChild(title); group.fields.forEach(key => { const wrapper = document.createElement('div'); wrapper.style.marginBottom = '4px'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = true; checkbox.id = key; checkboxes[key] = checkbox; const label = document.createElement('label'); label.htmlFor = key; label.textContent = ' ' + key + ': '; const valueSpan = document.createElement('span'); valueSpan.style.color = '#333'; valueSpan.textContent = data[key] || 'N.A.'; wrapper.appendChild(checkbox); wrapper.appendChild(label); wrapper.appendChild(valueSpan); content.appendChild(wrapper); }); if (group.key === 'agentBlock') { createPlainLinkRow('99.co查询', data.phoneLookupUrl); if (data.phoneSource && data.phoneSource.startsWith('99.co')) { createPlainInfoRow('号码来源', data.phoneLookupNote || data.phoneSource); } else if (data.phoneLookupNote) { createPlainInfoRow('99.co结果', data.phoneLookupNote); } else { createPlainInfoRow('号码来源', 'PropertyGuru'); } } }); const button = document.createElement('button'); button.textContent = '复制到剪贴板'; button.style.padding = '5px 10px'; button.style.fontSize = '13px'; button.style.cursor = 'pointer'; button.onclick = () => copyToClipboard(data, checkboxes, button); const buttonGroup = document.createElement('div'); buttonGroup.style.marginTop = '10px'; buttonGroup.appendChild(button); if (data.phoneNumber && data.phoneNumber !== 'N.A.' && !needs99CoPhoneLookup(data.phoneNumber)) { const whatsappBtn = document.createElement('button'); whatsappBtn.textContent = 'WhatsApp联系中介'; whatsappBtn.style.marginLeft = '10px'; whatsappBtn.style.padding = '5px 10px'; whatsappBtn.style.fontSize = '13px'; whatsappBtn.style.cursor = 'pointer'; whatsappBtn.onclick = () => { const url = buildWhatsAppLink(data.agentName, data.url, data.phoneNumber); window.open(url, '_blank'); }; buttonGroup.appendChild(whatsappBtn); } content.appendChild(buttonGroup); panel.appendChild(content); document.body.appendChild(panel); makeDraggable(panel, header); copyToClipboard(data, checkboxes, button); } function makeDraggable(panel, handle) { let isDragging = false; let offsetX, offsetY; handle.addEventListener('mousedown', (e) => { isDragging = true; offsetX = e.clientX - panel.getBoundingClientRect().left; offsetY = e.clientY - panel.getBoundingClientRect().top; panel.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', (e) => { if (isDragging) { panel.style.left = `${e.clientX - offsetX}px`; panel.style.top = `${e.clientY - offsetY}px`; panel.style.bottom = 'auto'; panel.style.right = 'auto'; } }); document.addEventListener('mouseup', () => { isDragging = false; panel.style.cursor = 'move'; }); } async function init() { const data = extractData(); await enrichPhoneFrom99Co(data); createPanel(data); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();