PropertyguruAssist

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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://greatest.deepsurf.us/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();
  }
})();