Skip to content
Go back

SUUMOで怪しい不動産会社を非表示にする方法【Tampermonkey】

公開:

最強のリモートワーク環境を作るために物件を絶賛探し中なのですが、
おとり物件を載せてそうな会社が邪魔くさいので、検索結果から排除する Tampermonkey スクリプトを作りました。共有します。

コピペで動きます。
もし SUUMO で物件探しをしていて、検索結果から特定の会社の物件を消し炭にしたいとお考えの方は使ってみてください。

Tampermonkey とは?

Webサイト上で JavaScript をいい感じに実行してくれる Chrome 拡張機能です。ブックマークレットより柔軟に動かせるので便利。

以下の記事なんかが参考になるかも。
【初心者向け】Tampermonkey で JavaScript に触ってみよう

できること

SUUMOの検索結果一覧ページで、指定した会社(KCコード)の掲載物件を消し炭にする(非表示にする)

利用例

例として、以下の会社(KCコード)を非表示にしたいとします。
※ あくまで例です。本当にヤバい会社を実例にすると怖いので、全く問題のない普通の不動産会社を例にしています。

TR(株)ナビホーム赤坂店: https://suumo.jp/chintai/kaisha/kc_030_182730001/
kc_030_182730001 ← このコードを使います

Before

画像の真ん中の物件がTR(株)ナビホーム赤坂店さん掲載の物件です。

before

After

今回作った Tampermonkey スクリプトを有効化すると、検索結果一覧から表示されなくなります。

after

専用UI

画面右下に、blocked(ブロックリストの数) / checked(チェック済みの物件の数) / removed(非表示にした物件の数) などを表示するようにしました。ちゃんと動いているかの確認もできます。

右下にUIが表示

スクリプト

// ==UserScript==
// @name         SUUMO: Hide listings by provider company KC
// @namespace    https://blog.devkey.jp/
// @version      1.0.0
// @description  SUUMO検索結果で、ブロックリストに指定した会社コード(KC)に一致した物件だけを非表示
// @match        https://suumo.jp/*
// @run-at       document-end
// ==/UserScript==

(() => {
  "use strict";

  // ブロックしたい不動産会社の会社コード(会社ページURLの /chintai/kaisha/kc_xxx_yyyy.../ 部分)
  // ※ 以下の二件はサンプルです。必ずあなたがブロックしたい会社のコードに置き換えてください。
  const BLOCKED_KC = new Set([
    "kc_030_182730001", // 例:TR(株)ナビホーム赤坂店
    "kc_000_000000000",
  ]);

  const MAX_CONCURRENT_FETCH = 32;
  const FETCH_DELAY_MS = 120;

  const KC_TOKEN_RE = /\/chintai\/kaisha\/(kc_\d{3}_\d+)\//;

  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  // バッジUI
  const UI_ID = "__suumo_kc_badge__";
  const ui = (() => {
    const existing = document.getElementById(UI_ID);
    if (existing) existing.remove();

    const wrap = document.createElement("div");
    wrap.id = UI_ID;
    wrap.style.cssText =
      "position:fixed;z-index:2147483647;right:12px;bottom:12px;" +
      "background:rgba(0,0,0,.86);color:#fff;border:1px solid rgba(255,255,255,.12);" +
      "padding:10px 12px;border-radius:12px;font:12px/1.45 system-ui;" +
      "box-shadow:0 8px 28px rgba(0,0,0,.35);max-width:360px;min-width:240px;";

    const style = document.createElement("style");
    style.textContent = `
      #${UI_ID} .row{display:flex;align-items:center;justify-content:space-between;gap:8px}
      #${UI_ID} .title{font-weight:700;font-size:12px;display:flex;align-items:center;gap:8px}
      #${UI_ID} .dot{width:9px;height:9px;border-radius:99px;background:#2ecc71;box-shadow:0 0 0 2px rgba(46,204,113,.15)}
      #${UI_ID}[data-state="idle"] .dot{background:#95a5a6;box-shadow:0 0 0 2px rgba(149,165,166,.15)}
      #${UI_ID} .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
      #${UI_ID} .muted{opacity:.82}
      #${UI_ID} .body{margin-top:8px}
    `;
    document.head.appendChild(style);

    wrap.innerHTML = `
      <div class="row">
        <div class="title">
          <span class="dot"></span>
          <span>SUUMO KC Filter</span>
          <span class="muted mono" id="kcState">RUNNING</span>
        </div>
      </div>
      <div class="body">
        <div class="muted">blocked: <span class="mono" id="kcBlocked">0</span></div>
        <div class="muted">queued: <span class="mono" id="kcQueued">0</span> / checked: <span class="mono" id="kcChecked">0</span> / removed: <span class="mono" id="kcRemoved">0</span></div>
        <div class="muted">active fetch: <span class="mono" id="kcActive">0</span></div>
      </div>
    `;
    document.body.appendChild(wrap);

    const els = {
      root: wrap,
      state: wrap.querySelector("#kcState"),
      blocked: wrap.querySelector("#kcBlocked"),
      queued: wrap.querySelector("#kcQueued"),
      checked: wrap.querySelector("#kcChecked"),
      removed: wrap.querySelector("#kcRemoved"),
      active: wrap.querySelector("#kcActive"),
    };

    return {
      setState(state) {
        els.root.dataset.state = state; // running | idle
        els.state.textContent = state.toUpperCase();
      },
      update({ blocked, queued, checked, removed, activeFetch }) {
        els.blocked.textContent = String(blocked);
        els.queued.textContent = String(queued);
        els.checked.textContent = String(checked);
        els.removed.textContent = String(removed);
        els.active.textContent = String(activeFetch);
      },
    };
  })();

  // 同時実行制限(簡易キュー)
  // ブロックしたい不動産かどうかチェックする際に
  //「詳細を見る」のリンク先を fetch をするが、
  // 100件以上をまとめて行うと重くなるのでキューで徐々に実行する
  let activeFetch = 0;
  const queue = [];
  function limit(task) {
    return new Promise((resolve) => {
      queue.push({ task, resolve });
      pump();
    });
  }
  function pump() {
    while (activeFetch < MAX_CONCURRENT_FETCH && queue.length) {
      const { task, resolve } = queue.shift();
      activeFetch++;
      Promise.resolve()
        .then(task)
        .then(resolve)
        .catch(() => resolve({ hide: false }))
        .finally(() => {
          activeFetch--;
          pump();
        });
    }
  }

  // 物件詳細ページから「提供元(先頭の会社)」のKCだけを取り出す
  function extractProviderKcToken(detailDoc, detailHtml, detailUrl) {

    {
      const m = String(detailUrl).match(KC_TOKEN_RE);
      if (m) return m[1];
    }

    const html = String(detailHtml || "");

    {
      const keys = ["この物件を取り扱う店舗", "取り扱い店舗"];
      for (const key of keys) {
        const idx = html.indexOf(key);
        if (idx !== -1) {
          const part = html.slice(idx, idx + 160_000);
          const m = part.match(KC_TOKEN_RE);
          if (m) return m[1];
        }
      }
    }

    {
      const cassettes = Array.from(detailDoc.querySelectorAll(".itemcassette, .itemcassette.l-space_medium"));
      for (const cas of cassettes) {
        const a = cas.querySelector('a[href*="/chintai/kaisha/"]');
        const href = a?.getAttribute("href") || "";
        const m = href.match(KC_TOKEN_RE);
        if (m) return m[1];
      }
    }

    {
      const head = html.slice(0, 200_000);
      const m = head.match(KC_TOKEN_RE);
      return m ? m[1] : null;
    }
  }

  async function shouldHideByDetailUrl(detailUrl) {
    if (!detailUrl) return { hide: false };

    let abs;
    try {
      abs = new URL(detailUrl, location.origin).toString();
    } catch {
      return { hide: false };
    }

    return await limit(async () => {
      try {
        const res = await fetch(abs, { credentials: "include" });
        if (!res.ok) return { hide: false };

        const html = await res.text();
        const doc = new DOMParser().parseFromString(html, "text/html");

        const providerKc = extractProviderKcToken(doc, html, abs);
        const hide = providerKc ? BLOCKED_KC.has(providerKc) : false;

        await sleep(FETCH_DELAY_MS);
        return { hide };
      } catch {
        return { hide: false };
      }
    });
  }

  function getRoomRows() {
    const root = document.querySelector("#js-bukkenList") || document.body;

    const clipInputs = Array.from(root.querySelectorAll("input.js-clipkey"));
    if (clipInputs.length) {
      const rows = clipInputs
        .map((inp) => inp.closest("tr") || inp.closest("li"))
        .filter(Boolean);
      return Array.from(new Set(rows));
    }

    return Array.from(root.querySelectorAll("ul.l-cassetteitem > li"));
  }

  // 順番に拾う(誤リンクを拾いにくい)
  function findDetailLinkInRow(row) {
    return (
      row.querySelector('a[href*="/chintai/jnc_"][href]') ||
      row.querySelector('a[href*="/chintai/bc_"][href]') ||
      row.querySelector('a[href*="bc="][href]') ||
      row.querySelector('a.js-cassette_link_href[href]')
    );
  }

  function cleanupEmptyCard(row) {
    const card = row.closest("ul.l-cassetteitem > li") || row.closest("li");
    if (!card) return;
    const still = card.querySelector("input.js-clipkey");
    if (!still) card.remove();
  }

  let removed = 0;
  let checked = 0;
  let queued = 0;

  async function processRow(row) {
    if (row.dataset.__kcProcessed) return;
    row.dataset.__kcProcessed = "1";

    const a = findDetailLinkInRow(row);
    if (!a?.href) return;

    queued++;
    ui.update({ blocked: BLOCKED_KC.size, queued, checked, removed, activeFetch });

    const { hide } = await shouldHideByDetailUrl(a.href);
    checked++;

    if (hide) {
      row.remove();
      removed++;
      cleanupEmptyCard(row);
    }

    ui.update({ blocked: BLOCKED_KC.size, queued, checked, removed, activeFetch });
  }

  let scanScheduled = false;
  function scheduleScan() {
    if (scanScheduled) return;
    scanScheduled = true;
    setTimeout(() => {
      scanScheduled = false;
      scan();
    }, 200);
  }

  function scan() {
    const rows = getRoomRows();
    if (!rows.length) {
      ui.setState("idle");
      ui.update({ blocked: BLOCKED_KC.size, queued, checked, removed, activeFetch });
      return;
    }

    ui.setState("running");
    ui.update({ blocked: BLOCKED_KC.size, queued, checked, removed, activeFetch });

    for (const row of rows) void processRow(row);
  }

  scan();

  const observeRoot = document.querySelector("#js-bukkenList") || document.documentElement;
  new MutationObserver(() => scheduleScan()).observe(observeRoot, { childList: true, subtree: true });
})();

※ SUUMO 側の HTML 構造が変わると動かなくなることがあります。その点はご了承ください。

さいごに

おとりっぽい物件を平気で載せてる不動産屋は結構多いので、ぜひ役立ててください!


Share this post on:

Previous Post
英語でGO!の収録単語でAnkiデッキを作ったので配布します
Next Post
【真似すれば合格できる】AWS Certified SysOps Administrator - Associate(SOA-C02)合格体験記