TrackingParameterRemover

remove tracking parameters

// ==UserScript==
// @name        TrackingParameterRemover
// @namespace   https://htsign.hateblo.jp
// @version     20240926-rev1
// @description remove tracking parameters
// @author      htsign
// @match       *://*/*
// @run-at      document-start
// @grant       GM_registerMenuCommand
// @grant       GM_getValue
// @grant       GM_setValue
// ==/UserScript==

const TRACKING_TAGS = [
  '#?utm_campaign',
  '#?utm_content',
  '#?utm_int',
  '#?utm_medium',
  '#?utm_source',
  '_hsmi',
  '_openstat',
  'action_object_map',
  'action_ref_map',
  'action_type_map',
  'fb_action_ids',
  'fb_action_types',
  'fb_ref',
  'fb_source',
  'ga_campaign',
  'ga_content',
  'ga_medium',
  'ga_place',
  'ga_source',
  'ga_term',
  'gs_l',
  'guccounter',
  'guce_referrer',
  'guce_referrer_sig',
  'gws_rd',
  'hmb_campaign',
  'hmb_medium',
  'hmb_source',
  'ref_src',
  'ref_url',
  'utm_campaign',
  'utm_cid',
  'utm_content',
  'utm_int',
  'utm_language',
  'utm_medium',
  'utm_name',
  'utm_place',
  'utm_pubreferrer',
  'utm_reader',
  'utm_source',
  'utm_swu',
  'utm_term',
  'utm_userid',
  'utm_viz_id',
  'yclid',
  '[email protected]',
  '[email protected]',
  '_encoding@amazon.*',
  'linkCode@amazon.*',
  'linkId@amazon.*',
  'pd_rd_*@amazon.*',
  'psc@amazon.*',
  'qid@amazon.*',
  'sbo@amazon.*',
  'sprefix@amazon.*',
  'sr@amazon.*',
  'tag@amazon.*',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  'bi?@google.*',
  'client@google.*',
  'dpr@google.*',
  'ei@google.*',
  'gws_rd@google.*',
  'oq@google.*',
  'sa@google.*',
  'sei@google.*',
  'source@google.*',
  'ved@google.*',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
  '[email protected]',
];

const createStyle = params => {
  const toKebab = s => s.replace(/(?<=[a-z])[A-Z]/g, m => `-${m.toLowerCase()}`);
  return Object.entries(params)
    .map(([key, val]) => `${toKebab(key)}: ${val};`)
    .join('; ');
};

const createWrapper = () => {
  const PADDING = 20;

  const wrapper = Object.assign(
    document.createElement('div'),
    {
      style: createStyle({
        position: 'fixed',
        width: 'fit-content',
        maxHeight: 'fit-content',
        left: `${PADDING}px`,
        top: `${PADDING}px`,
        zIndex: 2 ** 31 - 1,
      }),
    },
  );
  return document.body.appendChild(wrapper);
};

GM_registerMenuCommand('open settings', async ev => {
  const wrapper = createWrapper();
  const shadowRoot = wrapper.attachShadow({ mode: 'open' });
  const css = `
    :host {
      background-color: white;
      border: 2px solid #777;
      max-width: 600px;

      @media (prefers-color-scheme: dark) {
        background-color: #333;
      }
    }
    #list {
      display: block;
      white-space: pre;
      width: 400px;
      height: max(50vh, 600px);
    }
    #help {
      font: 16px sans-serif;
    }
    #help_pre {
      font: 14px monospace;
      line-height: 1;
    }
    #controller {
      display: flex;
      justify-content: space-between;
    }
    #content_controller {
      display: flex;
    }
    #reset_button {
      background-color: rgb(64 0 0);
    }
  `;

  /** @type {string[]} */
  const trackingTags = GM_getValue('tracking_tags', TRACKING_TAGS);

  const listArea = Object.assign(document.createElement('textarea'), {
    id: 'list',
    value: trackingTags.join('\n'),
  });
  const controllerBar = Object.assign(document.createElement('div'), { id: 'controller' });
  const contentController = Object.assign(document.createElement('div'), { id: 'content_controller' });
  const resetButton = Object.assign(document.createElement('button'), {
    id: 'reset_button',
    onclick() {
      listArea.value = trackingTags.join('\n');
    },
    textContent: 'Reset',
  });
  const confirmButton = Object.assign(document.createElement('button'), {
    id: 'confirm_button',
    onclick() {
      GM_setValue('tracking_tags', listArea.value.split('\n'));
    },
    textContent: 'Save',
  });
  const closeButton = Object.assign(document.createElement('button'), {
    onclick() {
      wrapper.remove();
    },
    textContent: 'Close',
  });

  const help = Object.assign(document.createElement('details'), { id: 'help' });
  help.append(
    Object.assign(document.createElement('summary'), { textContent: 'Help' }),
    Object.assign(document.createElement('pre'), {
      id: 'help_pre',
      textContent: `
standard style:

- <parameter-name>[@<host>[/<path>]]

  - e.g. "utm_source"
  - e.g. "[email protected]"

for hash keywords:

- #?<parameter-name>[@<host>[/<path>]]

others:

- blank lines are ignored
- placing "//" at the beginning of a line causes that line to be ignored
      `.trim(),
    }),
  );

  contentController.append(resetButton, confirmButton);
  controllerBar.append(contentController, closeButton);

  shadowRoot.append(listArea, help, controllerBar);
  shadowRoot.adoptedStyleSheets = [await new CSSStyleSheet().replace(css)];
});

(function() {
  'use strict';

  /**
   * @param {Location} loc
   * @returns {{ url: string, locationChanged: boolean }}
   */
  const removeTracking = loc => {
    let locationChanged = false;
    const escapables = Object.freeze({
      '.': '\.',
    });
    const wildcardCharacters = Object.freeze({
      '*': '.*',
      '?': '.',
    });
    const wildcardKeys = Object.keys(wildcardCharacters);

    /**
     * @param {string} pattern
     * @returns {RegExp}
     */
    const toRegExp = pattern => {
      /**
       *
       * @param {Record<string, string>} table
       * @param {string} s
       * @returns {string}
       */
      const replace = (table, s) => Object.entries(table).reduce((acc, [f, t]) => acc.split(f).join(t), s);

      const sanitized = replace(escapables, pattern);
      const inner = replace(wildcardCharacters, sanitized);
      return new RegExp('^' + inner + '$', 'i');
    };

    /**
     *
     * @param {string} pattern
     * @param {string} s
     * @returns {boolean}
     */
    const match = (pattern, s) => {
      if (wildcardKeys.some(c => pattern.includes(c))) {
        return toRegExp(pattern).test(s);
      }
      return pattern === s;
    };

    /**
     * @param {string} domain
     * @param {URLSearchParams} params
     * @param {string} pattern
     * @returns {string}
     */
    const deleteKeys = (domain, params, pattern) => {
      if (!domain || loc.hostname.split('.').some((_, i, arr) => match(domain, arr.slice(i).join('.')))) {
        for (const [key] of params) {
          if (match(pattern, key)) {
            params.delete(key);
            locationChanged = true;
          }
        }
      }
      return params.toString().split('%25').join('%');
    };

    /**
     * @param {string} search
     * @param {() => boolean} condition
     * @param {(arg: URLSearchParams) => any} callback
     * @returns {void}
     */
    const proc = (search, condition, callback) => {
      const params = new URLSearchParams(search);
      if (params.size === 0) return;
      if (!condition()) return;
      callback(params);
    };

    const url = new URL(loc);
    url.search = url.search.split('%25').join('\0'); // avoid to escape of original '%25'
    GM_getValue('tracking_tags', TRACKING_TAGS).forEach(tag => {
      if (tag === '' || tag.startsWith('//')) return;

      const [t, domain, pathname] = tag.split(/[@\/]/);

      if (t.startsWith('#?')) {
        proc(
          url.hash.slice(1),
          () => pathname == null || url.pathname === `/${pathname}`,
          params => {
            url.hash = deleteKeys(domain, params, t.slice(2));
          },
        );
      }
      else {
        if (!url.search) return;

        proc(
          url.search.slice(1).replace(/%(?!25)/g, '%25'),
          () => pathname == null || url.pathname === `/${pathname}`,
          params => {
            url.search = deleteKeys(domain, params, t);
          },
        );
      }
    });
    url.search = url.search.split('%00').join('%25'); // restore original '%25'
    return { url: url.href, locationChanged };
  };

  const { url, locationChanged } = removeTracking(location);
  if (locationChanged) {
    console.info(`TrackingParameterRemover: {${location.href}} => {${url}}`);
    location.replace(url);
  }
}());