PropertyguruAssist

one button click -> simplify propertyguru listing info for easily copy / paste

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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(/&quot;/g, '"')
      .replace(/&#34;/g, '"')
      .replace(/&amp;/g, '&')
      .replace(/&#x2F;/g, '/')
      .replace(/&lt;/g, '<')
      .replace(/&gt;/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();
  }
})();