Custom Filter for Ruten

Add additional custom front-end filters for ruten.com.tw.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Custom Filter for Ruten
// @namespace    https://github.com/rod24574575
// @description  Add additional custom front-end filters for ruten.com.tw.
// @version      0.2.4
// @license      MIT
// @author       rod24574575
// @homepage     https://github.com/rod24574575/ruten-filter
// @homepageURL  https://github.com/rod24574575/ruten-filter
// @supportURL   https://github.com/rod24574575/ruten-filter/issues
// @match        *://*.ruten.com.tw/find/*
// @match        *://*.ruten.com.tw/category/*
// @match        *://*.ruten.com.tw/item/*
// @run-at       document-idle
// @resource     preset_figure https://gist.githubusercontent.com/rod24574575/1f2276f895205e75964338235b751f80/raw/5961aef86dc3440d3950f6414c2aee7d1a73231c/figure.json
// @require      https://cdn.jsdelivr.net/npm/[email protected]/quicksettings.min.js
// @grant        GM.getResourceUrl
// @grant        GM.registerMenuCommand
// @grant        GM.getValue
// @grant        GM.setValue
// ==/UserScript==

// @ts-check
'use strict';

(function () {
  /**
   * @typedef {object} Settings
   * @property {Record<string,boolean>} presets
   * @property {boolean} hideAD
   * @property {boolean} hideRecommender
   * @property {boolean} hideOversea
   * @property {Record<string,boolean>} hideProductKeywords
   * @property {Record<string,boolean>} hideSellers
   * @property {number} hideSellerCreditLessThan
   */

  /**
   * @typedef {object} ComputedSettings
   * @property {RegExp | null} hideProductKeywordMatcher
   * @property {Set<string> | null} hideSellerSet
   */

  /**
   * @typedef {Omit<Settings, 'presets'> & ComputedSettings} ParsedSettings
   */

  /**
   * @template {*} T
   * @param {Record<string,T>} dst
   * @param {Record<string,T>} src
   */
  function mergeRecords(dst, src) {
    for (const [key, value] of Object.entries(src)) {
      if (value !== undefined) {
        dst[key] = value;
      }
    }
  }

  /**
   * @param {Record<string,boolean>} enabledMap
   * @returns {string[]}
   */
  function getEnabledArray(enabledMap) {
    /** @type {string[]} */
    const results = [];
    for (let [key, value] of Object.entries(enabledMap)) {
      key = key.trim();
      if (key && value === true) {
        results.push(key);
      }
    }
    return results;
  }

  /**
   * @param {string[]} enabledArray
   * @returns {Record<string,boolean>}
   */
  function getEnabledMap(enabledArray) {
    /** @type {Record<string,boolean>} */
    const results = {};
    for (let key of enabledArray) {
      key = key.trim();
      if (key) {
        results[key] = true;
      }
    }
    return results;
  }

  /**
   * @param {string} str
   * @returns {string}
   */
  function escapeRegExp(str) {
    return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
  }

  /**
   * @returns {Promise<Settings>}
   */
  async function loadSettings() {
    /** @type {Settings} */
    const defaultSettings = {
      presets: {},
      hideAD: true,
      hideRecommender: true,
      hideOversea: false,
      hideProductKeywords: {},
      hideSellers: {},
      hideSellerCreditLessThan: 0,
    };

    const entries = await Promise.all(
      Object.entries(defaultSettings).map(async ([key, value]) => {
        try {
          value = await GM.getValue(key, value);
        } catch (e) {
          console.warn(e);
        }
        return /** @type {[string, any]} */ ([key, value]);
      }),
    );
    return /** @type {Settings} */ (Object.fromEntries(entries));
  }

  /**
   * @type {ParsedSettings | null}
   */
  let cachedSettings = null;

  /**
   * @param {boolean} [force]
   * @returns {Promise<ParsedSettings>}
   */
  async function ensureSettings(force = false) {
    if (!force && cachedSettings) {
      return cachedSettings;
    }

    const {
      presets,
      hideAD,
      hideRecommender,
      hideOversea,
      hideProductKeywords,
      hideSellers,
      hideSellerCreditLessThan,
    } = await loadSettings();

    const presetResults = await Promise.allSettled(
      getEnabledArray(presets).map(async (preset) => {
        const url = await GM.getResourceUrl(`preset_${preset}`);
        const resp = await fetch(url);
        return /** @type {Promise<Settings>} */ (resp.json());
      }),
    );
    for (const presetResult of presetResults) {
      if (presetResult.status === 'rejected') {
        console.warn(presetResult.reason);
        continue;
      }

      const preset = presetResult.value;
      if (preset.hideProductKeywords) {
        mergeRecords(hideProductKeywords, preset.hideProductKeywords);
      }
      if (preset.hideSellers) {
        mergeRecords(hideSellers, preset.hideSellers);
      }
    }

    /** @type {ParsedSettings['hideProductKeywordMatcher']} */
    let hideProductKeywordMatcher = null;
    const hideProductKeywordsArray = getEnabledArray(hideProductKeywords);
    if (hideProductKeywordsArray.length > 0) {
      try {
        hideProductKeywordMatcher = new RegExp(
          hideProductKeywordsArray.map(escapeRegExp).join('|'),
        );
      } catch (e) {
        console.warn(e);
      }
    }

    /** @type {ParsedSettings['hideSellerSet']} */
    let hideSellerSet = null;
    const hideSellersArray = getEnabledArray(hideSellers);
    if (hideSellersArray.length > 0) {
      hideSellerSet = hideSellersArray.reduce((set, store) => {
        set.add(store);
        return set;
      }, new Set());
    }

    return (cachedSettings = {
      hideAD,
      hideRecommender,
      hideOversea,
      hideProductKeywords,
      hideSellers,
      hideSellerCreditLessThan,
      hideProductKeywordMatcher,
      hideSellerSet,
    });
  }

  /**
   * @typedef {Element & ElementCSSInlineStyle} ElementWithStyle
   */

  /**
   * @param {Element} productCard
   * @returns {any}
   */
  function getProductVueProps(productCard) {
    return /** @type {Element & { __vue__?: any }} */ (productCard).__vue__?.$props;
  }

  /**
   * @param {Element} el
   * @param {boolean} visible
   */
  function setVisible(el, visible) {
    /** @type {ElementWithStyle} */
    (el).style.display = visible ? '' : 'none';
  }

  /**
   * @param {Element} productCard
   * @returns {boolean}
   */
  function isAd(productCard) {
    return !!productCard.querySelector('.rt-product-card-ad-tag');
  }

  /**
   * @param {Element} productCard
   * @returns {boolean}
   */
  function isOversea(productCard) {
    return !!getProductVueProps(productCard)?.item?.ifOversea;
  }

  /**
   * @param {Element} productCard
   * @param {RegExp} matcher
   * @returns {boolean}
   */
  function isProduceKeywordMatch(productCard, matcher) {
    const name = getProductVueProps(productCard)?.item?.name;
    if (!name) {
      return false;
    }
    return matcher.test(name);
  }

  /**
   * @param {Element} productCard
   * @param {Set<number|string>} storeSet
   * @returns {boolean}
   */
  function isSellers(productCard, storeSet) {
    const sellerInfo = getProductVueProps(productCard)?.item?.sellerInfo;
    if (!sellerInfo) {
      return false;
    }

    const { sellerId, sellerNick, sellerStoreName } = sellerInfo;

    /** @type {number|undefined} */
    let sellerIdNumber;
    /** @type {string|undefined} */
    let sellerIdString;
    if (typeof sellerId === 'string') {
      sellerIdNumber = parseInt(sellerId);
      sellerIdString = sellerId;
    } else if (typeof sellerId === 'number') {
      sellerIdNumber = sellerId;
      sellerIdString = String(sellerId);
    }

    return !!(
      (sellerIdNumber && storeSet.has(sellerIdNumber)) ||
      (sellerIdString && storeSet.has(sellerIdString)) ||
      (sellerNick && storeSet.has(sellerNick)) ||
      (sellerStoreName && storeSet.has(sellerStoreName))
    );
  }

  /**
   * @param {Element} productCard
   * @param {number} value
   * @returns {boolean}
   */
  function isSellerCreditLessThan(productCard, value) {
    /** @type {unknown} */
    const rawCredit = getProductVueProps(productCard)?.item?.sellerInfo?.sellerCredit;

    /** @type {number} */
    let credit;
    if (typeof rawCredit === 'number') {
      credit = rawCredit;
    } else if (typeof rawCredit === 'string') {
      credit = parseInt(rawCredit);
      if (isNaN(credit)) {
        return false;
      }
    } else {
      return false;
    }
    return credit < value;
  }

  /**
   * @param {Element} productCard
   * @param {boolean} visible
   */
  function setProductVisible(productCard, visible) {
    const wrapper = productCard.closest('.rt-slideshow-inner > *, .search-result-container > *');
    if (!wrapper) {
      return;
    }
    setVisible(wrapper, visible);
  }

  /**
   * @param {boolean} [force]
   */
  async function run(force = false) {
    const {
      hideAD,
      hideRecommender,
      hideOversea,
      hideProductKeywordMatcher,
      hideSellerSet,
      hideSellerCreditLessThan,
    } = await ensureSettings(force);

    const overseaContainers = document.querySelectorAll('.ebay-result-container');
    if (overseaContainers.length > 0) {
      for (const el of overseaContainers) {
        setVisible(el, !hideOversea);
      }
    }

    const recommenders = document.querySelectorAll('.recommender-keyword');
    if (recommenders.length > 0) {
      for (const el of recommenders) {
        setProductVisible(el, !hideRecommender);
      }
    }

    const productCards = document.querySelectorAll('.rt-product-card');
    if (productCards.length > 0) {
      const visibles = [...productCards].map((productCard) => {
        try {
          return !(
            (hideAD && isAd(productCard)) ||
            (hideOversea && isOversea(productCard)) ||
            (hideProductKeywordMatcher &&
              isProduceKeywordMatch(productCard, hideProductKeywordMatcher)) ||
            (hideSellerSet && isSellers(productCard, hideSellerSet)) ||
            (hideSellerCreditLessThan > 0 &&
              isSellerCreditLessThan(productCard, hideSellerCreditLessThan))
          );
        } catch (e) {
          console.warn(e);
          return true;
        }
      });
      for (let i = productCards.length - 1; i >= 0; --i) {
        setProductVisible(productCards[i], visibles[i]);
      }
    }
  }

  let running = false;
  const mutationObserver = new MutationObserver(async () => {
    if (running) {
      return;
    }
    running = true;
    await run();
    running = false;
  });
  mutationObserver.observe(document.body, {
    childList: true,
    subtree: true,
  });
  run();

  /**
   * @typedef { typeof window & { QuickSettings?: import('quicksettings').default } } WindowWithQuickSettings
   */

  const QuickSettings = /** @type {WindowWithQuickSettings} */ (window).QuickSettings;

  /**
   * @returns {Promise<void>}
   */
  async function configure() {
    if (!QuickSettings) {
      return;
    }

    const settings = await loadSettings();

    return new Promise((resolve) => {
      /**
       * @template {keyof Settings} T
       * @param {T} key
       * @param {Settings[T]} value
       */
      function updateSettings(key, value) {
        settings[key] = value;
      }

      function destroy() {
        wrapper.remove();
        // HACK: workaround for qs js error bugs
        window.setTimeout(() => {
          panel.destroy();
        }, 0);
      }

      async function apply() {
        destroy();
        await Promise.allSettled(
          Object.entries(settings).map(async ([key, value]) => {
            return GM.setValue(key, value);
          }),
        );
        resolve();

        // Do not wait for running.
        run(true);
      }

      function cancel() {
        destroy();
        resolve();
      }

      const wrapper = document.body.appendChild(document.createElement('div'));
      Object.assign(wrapper.style, {
        position: 'fixed',
        left: '0',
        top: '0',
        width: '100vw',
        height: '100vh',
        'z-index': '10000',
        background: 'rgba(0,0,0,0.6)',
      });

      const panel = QuickSettings.create(0, 0, 'Configure', wrapper)
        .addBoolean('Use preset config: figure', settings.presets['figure'] ?? false, (value) => {
          updateSettings('presets', { ...settings.presets, figure: value });
        })
        .addBoolean('Hide AD', settings.hideAD, (value) => {
          updateSettings('hideAD', value);
        })
        .addBoolean('Hide recommender', settings.hideRecommender, (value) => {
          updateSettings('hideRecommender', value);
        })
        .addBoolean('Hide oversea', settings.hideOversea, (value) => {
          updateSettings('hideOversea', value);
        })
        .addText(
          'Hide products that match keywords (separated by comma)',
          getEnabledArray(settings.hideProductKeywords).join(','),
          (value) => {
            updateSettings('hideProductKeywords', getEnabledMap(value.split(',')));
          },
        )
        .addText(
          'Hide sellers by name/id (separated by comma)',
          getEnabledArray(settings.hideSellers).join(','),
          (value) => {
            updateSettings('hideSellers', getEnabledMap(value.split(',')));
          },
        )
        .addNumber(
          'Hide sellers with credit less than',
          0,
          Infinity,
          settings.hideSellerCreditLessThan,
          1,
          (value) => {
            updateSettings('hideSellerCreditLessThan', value);
          },
        )
        .addButton('Apply', apply)
        .addButton('Cancel', cancel);

      const { width: wrapperWidth, height: wrapperHeight } = wrapper.getBoundingClientRect();
      const { width, height } = /** @type {typeof panel & { _panel: Element } } */ (
        panel
      )._panel.getBoundingClientRect();
      panel.setPosition((wrapperWidth - width) / 2, (wrapperHeight - height) / 2);
    });
  }

  if (QuickSettings) {
    let configuring = false;
    GM.registerMenuCommand('Configure', async () => {
      if (configuring) {
        return;
      }
      configuring = true;
      await configure();
      configuring = false;
    });
  }
})();